From a08b8ab7c68b4a4b5c54906c928c04ad177db4c1 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Jul 2026 15:40:54 +0000 Subject: [PATCH 01/11] fix(config): thread JIRA authType through configMapper + self-correcting review-policy file Address review feedback on PR #1483: - Thread the optional JIRA `authType` through the runtime DB-load path (`configMapper` `JiraIntegrationConfig`, `ProjectConfigRaw['jira']`, and `buildJiraConfig`). `buildJiraConfig` hand-picks fields, so `authType` was dropped before `jiraConfigSchema` re-parses and `ProjectConfig.jira.authType` always loaded back as `undefined`. Add mapper-level regression tests. - Reword `JiraCredentials.authType` JSDoc so it no longer overstates behavior: the field is reserved on the credentials shape but no call site populates it yet. - Write the review-event-policy file UNCONDITIONALLY with the run's resolved policy (both `all` and `comment-only`) so it self-corrects instead of relying on `/tmp` never being reused. CLI env > file > default precedence unchanged. Add tests asserting the write; mock only `writeFileSync` to avoid real disk I/O. Co-Authored-By: Claude Opus 4.8 --- src/backends/secretOrchestrator.ts | 20 +++--- src/config/reviewEventPolicy.ts | 4 +- src/db/repositories/configMapper.ts | 8 +++ src/jira/types.ts | 11 +-- .../unit/backends/secretOrchestrator.test.ts | 71 +++++++++++++++++++ .../unit/db/repositories/configMapper.test.ts | 18 +++++ 6 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/backends/secretOrchestrator.ts b/src/backends/secretOrchestrator.ts index 59968a25..d70d4a68 100644 --- a/src/backends/secretOrchestrator.ts +++ b/src/backends/secretOrchestrator.ts @@ -153,16 +153,20 @@ export async function buildExecutionPlan( // Build per-project secrets with CASCADE env var injections const projectSecrets = await augmentProjectSecrets(project, agentType, input); - // Write the review event policy to a well-known file so cascade-tools can read - // it even when the env var is stripped by the claude subprocess chain. + // Write the resolved review event policy to a well-known file so cascade-tools + // can read it even when the env var is stripped by the claude subprocess chain. // (@anthropic-ai/claude-code ≤ 2.1.185 does not forward all custom env vars to // bash subprocesses; the file lives in the container's ephemeral /tmp.) - if (isCommentOnlyReview(resolveReviewEventPolicy(project, agentType))) { - try { - writeFileSync(REVIEW_EVENT_POLICY_FILE, 'comment-only', 'utf-8'); - } catch { - // Non-fatal — env-var mechanism remains the primary path - } + // + // Written UNCONDITIONALLY (both `all` and `comment-only`) so the file always + // reflects the current run's policy. This is self-correcting: if a `/tmp` path + // is ever reused across runs (non-container execution or a future change), an + // `all` run overwrites any stale `comment-only` file instead of inheriting it. + // The CLI's env > file > default precedence is unchanged. + try { + writeFileSync(REVIEW_EVENT_POLICY_FILE, resolveReviewEventPolicy(project, agentType), 'utf-8'); + } catch { + // Non-fatal — env-var mechanism remains the primary path } // Inject pre-seeded progress comment ID so the subprocess finds it at startup diff --git a/src/config/reviewEventPolicy.ts b/src/config/reviewEventPolicy.ts index 55b4c226..b4b64795 100644 --- a/src/config/reviewEventPolicy.ts +++ b/src/config/reviewEventPolicy.ts @@ -48,7 +48,9 @@ export const REVIEW_EVENT_POLICY_ENV_VAR = 'CASCADE_REVIEW_EVENT_POLICY'; * subprocess chain (observed with @anthropic-ai/claude-code ≤ 2.1.185 — the * bun-compiled binary does not forward all custom env vars to bash subprocesses). * The path is fixed inside the ephemeral worker container's /tmp, so there is no - * cross-run collision. + * cross-run collision. Written UNCONDITIONALLY with the run's resolved policy + * (both `all` and `comment-only`), so the file always reflects the current run + * and self-corrects if a /tmp path is ever reused. */ export const REVIEW_EVENT_POLICY_FILE = '/tmp/cascade-review-event-policy'; diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 0f1c62ba..1c2deb19 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -24,6 +24,8 @@ export interface TrelloIntegrationConfig { export interface JiraIntegrationConfig { projectKey: string; baseUrl: string; + /** Optional JIRA auth mode (non-secret config, mirrors `baseUrl`). See jiraConfigSchema. */ + authType?: 'basic' | 'scoped'; statuses: Record; issueTypes?: Record; customFields?: { cost?: string }; @@ -139,6 +141,7 @@ export interface ProjectConfigRaw { jira?: { projectKey: string; baseUrl: string; + authType?: 'basic' | 'scoped'; statuses: Record; issueTypes?: Record; customFields?: { cost?: string }; @@ -264,6 +267,11 @@ function buildJiraConfig(config: JiraIntegrationConfig): ProjectConfigRaw['jira' return { projectKey: config.projectKey, baseUrl: config.baseUrl, + // Thread the optional auth mode through the DB-load path so + // ProjectConfig.jira.authType survives validateConfig (MNG-1736). Without + // this hand-pick, the field is dropped before jiraConfigSchema re-parses, + // so a persisted authType would always load back as `undefined`. + authType: config.authType, statuses: config.statuses, issueTypes: config.issueTypes, customFields: config.customFields, diff --git a/src/jira/types.ts b/src/jira/types.ts index d055e052..516427f5 100644 --- a/src/jira/types.ts +++ b/src/jira/types.ts @@ -3,10 +3,13 @@ export interface JiraCredentials { apiToken: string; baseUrl: string; /** - * Optional JIRA authentication mode carried through `withJiraCredentials`. - * Non-secret config (mirrors `baseUrl`), NOT a separate credential role. - * `'basic'` = classic site-token mode; `'scoped'` = scoped gateway-token - * mode. Absent ⇒ treated as `'basic'`. Later stories consume this field. + * Optional JIRA authentication mode. Non-secret config (mirrors `baseUrl`), + * NOT a separate credential role. `'basic'` = classic site-token mode; + * `'scoped'` = scoped gateway-token mode. Absent ⇒ treated as `'basic'`. + * + * Reserved on the credentials shape so `withJiraCredentials` can carry it once + * a later story populates it — no call site sets it yet (`JiraIntegration.withCredentials` + * still builds `{ email, apiToken, baseUrl }` only). Later stories consume this field. */ authType?: 'basic' | 'scoped'; } diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts index c0be03de..29143ea4 100644 --- a/tests/unit/backends/secretOrchestrator.test.ts +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -63,6 +63,14 @@ vi.mock('../../../src/backends/sidecarManager.js', () => ({ createCompletionArtifacts: vi.fn().mockReturnValue({}), })); +// Spy on writeFileSync only (preserve every other fs function) so we can assert +// the review-event-policy file write without touching the real /tmp path. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeFileSync: vi.fn() }; +}); + +import { writeFileSync } from 'node:fs'; import { createIntegrationChecker, resolveEffectiveCapabilities, @@ -75,12 +83,14 @@ import { injectRunLinkSecrets, } from '../../../src/backends/secretOrchestrator.js'; import type { AgentEngine } from '../../../src/backends/types.js'; +import { REVIEW_EVENT_POLICY_FILE } from '../../../src/config/reviewEventPolicy.js'; import type { UpdateChannel } from '../../../src/config/updateChannel.js'; import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; import { getDashboardUrl } from '../../../src/utils/runLink.js'; const mockGetDashboardUrl = vi.mocked(getDashboardUrl); +const mockWriteFileSync = vi.mocked(writeFileSync); const mockGetSentryIntegrationConfig = vi.mocked(getSentryIntegrationConfig); const mockBuildPromptContext = vi.mocked(buildPromptContext); const mockCreateIntegrationChecker = vi.mocked(createIntegrationChecker); @@ -363,6 +373,67 @@ describe('buildExecutionPlan', () => { }); }); + // The review-event-policy file is written UNCONDITIONALLY with the run's + // resolved policy so it self-corrects if a /tmp path is ever reused. The CLI's + // env > file > default precedence still holds (see resolveEventPolicyFromEnv). + describe('review event policy file write', () => { + it('writes the resolved policy (all) unconditionally under the default policy', async () => { + await buildExecutionPlan( + 'review', + makeInput(makeProject(), 'manual'), + '/repo', + noopLogWriter, + noopAgentLogger, + undefined, + false, + 'claude-code', + engine, + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith(REVIEW_EVENT_POLICY_FILE, 'all', 'utf-8'); + }); + + it('writes comment-only when the review agent policy is comment-only', async () => { + const project = makeProject({ agentReviewEventPolicies: { review: 'comment-only' } }); + + await buildExecutionPlan( + 'review', + makeInput(project, 'manual'), + '/repo', + noopLogWriter, + noopAgentLogger, + undefined, + false, + 'claude-code', + engine, + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + REVIEW_EVENT_POLICY_FILE, + 'comment-only', + 'utf-8', + ); + }); + + it('writes all for a non-review agent even on a comment-only project (self-correcting)', async () => { + const project = makeProject({ agentReviewEventPolicies: { review: 'comment-only' } }); + + await buildExecutionPlan( + 'implementation', + makeInput(project, 'manual'), + '/repo', + noopLogWriter, + noopAgentLogger, + undefined, + false, + 'claude-code', + engine, + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith(REVIEW_EVENT_POLICY_FILE, 'all', 'utf-8'); + }); + }); + // MNG-1685: the native-tool engine family (claude-code/codex/opencode) renders // plan.availableTools into the system-prompt tool list, so dropping the disabled // posting tool manifests here is the authoritative gate for that family. diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 308d7eaf..8d54b1da 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -511,6 +511,24 @@ describe('mapProjectRow', () => { expect(result.jira?.statuses).toEqual({ splitting: 'Briefing', todo: 'To Do' }); }); + // MNG-1736: the runtime DB-load path (not just ProjectConfigSchema.parse) must + // preserve the optional authType, otherwise a persisted value silently loads + // back as `undefined`. buildJiraConfig hand-picks fields, so it has to copy it. + it('preserves jira authType through the mapper', () => { + const result = mapProjectRow( + makeInput({ + trelloConfig: undefined, + jiraConfig: { ...jiraConfig, authType: 'scoped' }, + }), + ); + expect(result.jira?.authType).toBe('scoped'); + }); + + it('leaves jira authType undefined when not configured (backward compat)', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, jiraConfig })); + expect(result.jira?.authType).toBeUndefined(); + }); + it('builds linear config with teamId, statuses, and labels', () => { const result = mapProjectRow(makeInput({ trelloConfig: undefined, linearConfig })); expect(result.linear?.teamId).toBe('team-abc123'); From 2cc687bec8c0c5152641f6f73694dd1a1504f74c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 10 Jul 2026 17:43:28 +0200 Subject: [PATCH 02/11] refactor(jira): export JiraAuthType from config-schema as single source of truth Eliminates the duplicated 'basic' | 'scoped' literal union that lived independently in JiraConfig, JiraCredentials, and jiraConfigSchema. Adding a third auth mode now requires one edit in config-schema.ts only. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_011ZaMpedeE8PXsix84cWsq1 --- src/integrations/pm/jira/config-schema.ts | 3 +++ src/jira/types.ts | 6 +++++- src/pm/config.ts | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/integrations/pm/jira/config-schema.ts b/src/integrations/pm/jira/config-schema.ts index c4e3cfc6..f1ebe293 100644 --- a/src/integrations/pm/jira/config-schema.ts +++ b/src/integrations/pm/jira/config-schema.ts @@ -81,3 +81,6 @@ export const jiraConfigSchema = z .describe('JIRA project integration config'); export type JiraIntegrationConfig = z.infer; + +/** Shared union for JIRA authentication mode — single source of truth. */ +export type JiraAuthType = NonNullable['authType']>; diff --git a/src/jira/types.ts b/src/jira/types.ts index 516427f5..9175ffda 100644 --- a/src/jira/types.ts +++ b/src/jira/types.ts @@ -1,3 +1,7 @@ +import type { JiraAuthType } from '../integrations/pm/jira/config-schema.js'; + +export type { JiraAuthType }; + export interface JiraCredentials { email: string; apiToken: string; @@ -11,5 +15,5 @@ export interface JiraCredentials { * a later story populates it — no call site sets it yet (`JiraIntegration.withCredentials` * still builds `{ email, apiToken, baseUrl }` only). Later stories consume this field. */ - authType?: 'basic' | 'scoped'; + authType?: JiraAuthType; } diff --git a/src/pm/config.ts b/src/pm/config.ts index 60b0739e..3955665e 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -6,6 +6,7 @@ * unified `project.pm.config` or the legacy top-level fields. */ +import type { JiraAuthType } from '../integrations/pm/jira/config-schema.js'; import type { ProjectConfig } from '../types/index.js'; /** Trello-specific configuration (from project_integrations JSONB) */ @@ -25,7 +26,7 @@ export interface JiraConfig { * `'basic'` = classic site-token mode; `'scoped'` = scoped gateway-token * mode. Absent ⇒ treated as `'basic'`. Later stories consume this field. */ - authType?: 'basic' | 'scoped'; + authType?: JiraAuthType; statuses: Record; issueTypes?: Record; customFields?: { cost?: string }; From 66d31873aec5f7eb4e5b6c10ddbe4838461138de Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 17:48:11 +0200 Subject: [PATCH 03/11] feat(jira): add shared resolveJiraApiBaseUrl host resolver with per-baseUrl cloudId cache (#1485) Co-authored-by: Cascade Bot --- src/jira/api-host.ts | 45 ++++++++++++ src/jira/client.ts | 33 +++++++-- tests/unit/jira/api-host.test.ts | 119 +++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/jira/api-host.ts create mode 100644 tests/unit/jira/api-host.test.ts diff --git a/src/jira/api-host.ts b/src/jira/api-host.ts new file mode 100644 index 00000000..268dab43 --- /dev/null +++ b/src/jira/api-host.ts @@ -0,0 +1,45 @@ +/** + * Shared JIRA REST v3 host resolver. + * + * Every REST v3 call site must route through `resolveJiraApiBaseUrl(creds)` so + * scoped API tokens hit the Atlassian gateway consistently and no divergent + * host-selection copies exist. Consuming this single resolver (rather than + * hand-picking a host per call site) is the JIRA analogue of the shared + * auth-header helper enforced by `auth-header-provenance.test.ts`. + * + * Host selection (confirmed live in MNG-1735): + * - `authType` basic / absent ⇒ the tenant's site URL (`creds.baseUrl`, e.g. + * `https://acme.atlassian.net`). Classic site-token behavior, unchanged. + * - `authType === 'scoped'` ⇒ the Atlassian REST gateway URL + * `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved + * from the tenant's site `/_edge/tenant_info` endpoint and cached per + * `baseUrl` (via `jiraClient.getCloudId`). + * + * Note: both modes still authenticate via HTTP Basic with `email:api_token`; + * `authType` selects the host, not the auth scheme. `accessible-resources` is + * intentionally NOT used as a cloudId fallback for scoped API tokens — it is + * OAuth 2.0 / 3LO guidance and returns 401 for scoped API tokens (MNG-1735). + */ + +import { jiraClient } from './client.js'; +import type { JiraCredentials } from './types.js'; + +/** Atlassian REST gateway origin used to route scoped API tokens. */ +const ATLASSIAN_GATEWAY_ORIGIN = 'https://api.atlassian.com'; + +/** + * Resolve the REST v3 base host for a set of JIRA credentials. + * + * @param creds - JIRA credentials, including the optional `authType`. + * @returns For basic / absent auth, the tenant site URL (`creds.baseUrl`). For + * scoped auth, the Atlassian gateway URL + * `https://api.atlassian.com/ex/jira/{cloudId}`. + */ +export async function resolveJiraApiBaseUrl(creds: JiraCredentials): Promise { + if (creds.authType !== 'scoped') { + return creds.baseUrl; + } + + const cloudId = await jiraClient.getCloudId(creds); + return `${ATLASSIAN_GATEWAY_ORIGIN}/ex/jira/${cloudId}`; +} diff --git a/src/jira/client.ts b/src/jira/client.ts index c00f6799..16e32a28 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -39,11 +39,19 @@ function getClient(): Version3Client { }); } -let cachedCloudId: string | null = null; +/** + * In-memory JIRA cloudId cache keyed by `baseUrl`. + * + * The router/dashboard process serves multiple projects, so a single + * module-level slot would collide across tenants once cloudId becomes + * load-bearing on every scoped request. Mirrors the per-baseUrl Map used by + * the platform client (`src/router/platformClients/jira.ts`). + */ +const cloudIdCache = new Map(); /** @internal Visible for testing only */ export function _resetCloudIdCache(): void { - cachedCloudId = null; + cloudIdCache.clear(); } export const jiraClient = { @@ -249,9 +257,20 @@ export const jiraClient = { return getClient().myself.getCurrentUser(); }, - async getCloudId(): Promise { - if (cachedCloudId) return cachedCloudId; - const creds = getJiraCredentials(); + /** + * Resolve the tenant cloudId from the site `/_edge/tenant_info` endpoint, + * caching the result per `baseUrl`. + * + * @param explicitCreds - Optional credentials to use instead of the + * `withJiraCredentials()` scope. Pass these when resolving outside an + * AsyncLocalStorage scope (e.g. the shared `resolveJiraApiBaseUrl` host + * resolver, which receives credentials directly). Absent ⇒ falls back to + * the ambient scoped credentials. + */ + async getCloudId(explicitCreds?: JiraCredentials): Promise { + const creds = explicitCreds ?? getJiraCredentials(); + const cached = cloudIdCache.get(creds.baseUrl); + if (cached) return cached; const response = await fetch(`${creds.baseUrl}/_edge/tenant_info`, { headers: { Authorization: `Basic ${Buffer.from(`${creds.email}:${creds.apiToken}`).toString('base64')}`, @@ -264,8 +283,8 @@ export const jiraClient = { if (!data.cloudId) { throw new Error('JIRA tenant_info response missing cloudId'); } - cachedCloudId = data.cloudId; - return cachedCloudId; + cloudIdCache.set(creds.baseUrl, data.cloudId); + return data.cloudId; }, async addCommentReaction(issueId: string, commentId: string, emojiId: string): Promise { diff --git a/tests/unit/jira/api-host.test.ts b/tests/unit/jira/api-host.test.ts new file mode 100644 index 00000000..ec2a3d3a --- /dev/null +++ b/tests/unit/jira/api-host.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// getCloudId() resolves cloudId via a direct `fetch()` — it never instantiates +// Version3Client — so a bare stub keeps the real (heavy) jira.js import out of +// this suite without affecting the code path under test. +vi.mock('jira.js', () => ({ + Version3Client: vi.fn(), +})); + +import { resolveJiraApiBaseUrl } from '../../../src/jira/api-host.js'; +import { _resetCloudIdCache } from '../../../src/jira/client.js'; +import type { JiraCredentials } from '../../../src/jira/types.js'; + +describe('resolveJiraApiBaseUrl', () => { + const baseUrl = 'https://acme.atlassian.net'; + const basicCreds: JiraCredentials = { + email: 'bot@example.com', + apiToken: 'jira-token', + baseUrl, + }; + const expectedAuth = `Basic ${Buffer.from('bot@example.com:jira-token').toString('base64')}`; + + beforeEach(() => { + _resetCloudIdCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('host selection', () => { + it('returns the site baseUrl when authType is absent (never hits the network)', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await resolveJiraApiBaseUrl(basicCreds); + + expect(result).toBe(baseUrl); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns the site baseUrl when authType is basic (never hits the network)', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await resolveJiraApiBaseUrl({ ...basicCreds, authType: 'basic' }); + + expect(result).toBe(baseUrl); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns the Atlassian gateway URL for scoped auth', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-abc-123' }), { status: 200 }), + ); + + const result = await resolveJiraApiBaseUrl({ ...basicCreds, authType: 'scoped' }); + + expect(result).toBe('https://api.atlassian.com/ex/jira/cloud-abc-123'); + // cloudId is discovered via the site /_edge/tenant_info flow with Basic auth. + expect(fetchSpy).toHaveBeenCalledWith( + 'https://acme.atlassian.net/_edge/tenant_info', + expect.objectContaining({ headers: { Authorization: expectedAuth } }), + ); + }); + }); + + describe('cloudId caching', () => { + it('resolves cloudId once and caches it per baseUrl', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-abc-123' }), { status: 200 }), + ); + + const scopedCreds: JiraCredentials = { ...basicCreds, authType: 'scoped' }; + const first = await resolveJiraApiBaseUrl(scopedCreds); + const second = await resolveJiraApiBaseUrl(scopedCreds); + + expect(first).toBe('https://api.atlassian.com/ex/jira/cloud-abc-123'); + expect(second).toBe('https://api.atlassian.com/ex/jira/cloud-abc-123'); + // tenant_info fetched exactly once — the second call is served from cache. + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it('resolves cloudId separately for each distinct baseUrl', async () => { + const globexBaseUrl = 'https://globex.atlassian.net'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + const cloudId = url.startsWith(globexBaseUrl) ? 'cloud-globex' : 'cloud-acme'; + return new Response(JSON.stringify({ cloudId }), { status: 200 }); + }); + + const acme = await resolveJiraApiBaseUrl({ ...basicCreds, authType: 'scoped' }); + const globex = await resolveJiraApiBaseUrl({ + ...basicCreds, + baseUrl: globexBaseUrl, + authType: 'scoped', + }); + // Re-resolving the first tenant must be served from cache (no new fetch). + const acmeAgain = await resolveJiraApiBaseUrl({ ...basicCreds, authType: 'scoped' }); + + expect(acme).toBe('https://api.atlassian.com/ex/jira/cloud-acme'); + expect(globex).toBe('https://api.atlassian.com/ex/jira/cloud-globex'); + expect(acmeAgain).toBe('https://api.atlassian.com/ex/jira/cloud-acme'); + // One fetch per distinct baseUrl (acme + globex); acmeAgain is cached. + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); +}); From 03358099a5e2727f1cfac623eb20808c61b57efa Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 17:48:56 +0200 Subject: [PATCH 04/11] feat(jira): thread authType through worker and CLI credential scopes (#1484) * feat(jira): thread authType through worker and CLI credential scopes Carry the JIRA authType ('basic' | 'scoped') into the in-worker and CLI credential scopes so agent runs and friction reports route JIRA REST calls through the correct host. - secretBuilder injects CASCADE_JIRA_AUTH_TYPE (= jiraConfig.authType ?? 'basic') alongside CASCADE_JIRA_BASE_URL. - cli/base wrapWithCredentialScopes threads CASCADE_JIRA_AUTH_TYPE into withJiraCredentials; synthesizeProjectFromEnv sets jira.authType from it. - JiraIntegration.withCredentials reads getJiraConfig(project)?.authType and passes it into withJiraCredentials. - reportFriction env-synthesized JIRA config includes authType. - New shared normalizeJiraAuthType() validates the raw env var back into the 'basic' | 'scoped' domain, defaulting to 'basic' for absent/unknown values. Refs: https://linear.app/issue/MNG-1741 Co-Authored-By: Claude Opus 4.8 * test(cli): make review-event-policy sidecar test hermetic against policy file resolveEventPolicyFromEnv() falls back to reading REVIEW_EVENT_POLICY_FILE (a hardcoded global /tmp path) when CASCADE_REVIEW_EVENT_POLICY is absent or invalid. The sibling suite create-pr-review.test.ts writes that file, and unit-core runs test files in parallel forks (isolate: false), so a leftover policy file could leak 'comment-only' into this suite's env-absent / invalid cases and fail them (surfaced when both files land in the same `--changed` subset). Remove the file in the policy suite's beforeEach/afterEach so it is hermetic and never leaks the file to sibling suites. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/backends/secretBuilder.ts | 4 + src/cli/base.ts | 10 ++- src/gadgets/pm/core/reportFriction.ts | 2 + src/jira/authType.ts | 32 ++++++++ src/pm/jira/integration.ts | 7 +- tests/unit/backends/secretBuilder.test.ts | 41 ++++++++++ tests/unit/cli/credential-scoping.test.ts | 76 +++++++++++++++++++ .../cli/scm/create-pr-review-sidecar.test.ts | 10 +++ .../gadgets/pm/core/reportFriction.test.ts | 70 +++++++++++++++++ tests/unit/jira/authType.test.ts | 37 +++++++++ tests/unit/pm/jira/integration.test.ts | 32 ++++++++ 11 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 src/jira/authType.ts create mode 100644 tests/unit/jira/authType.test.ts diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 3c3315c1..5c445209 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -97,6 +97,10 @@ export async function augmentProjectSecrets( projectSecrets.CASCADE_JIRA_PROJECT_KEY = jiraConfig.projectKey; projectSecrets.CASCADE_JIRA_BASE_URL = jiraConfig.baseUrl; projectSecrets.JIRA_BASE_URL = jiraConfig.baseUrl; + // Carry the JIRA auth mode into the worker so in-worker JIRA calls + // (agent runs, friction reports) route through the correct host. Absent + // config ⇒ 'basic' (the historical default), preserving existing projects. + projectSecrets.CASCADE_JIRA_AUTH_TYPE = jiraConfig.authType ?? 'basic'; if (jiraConfig.statuses) { projectSecrets.CASCADE_JIRA_STATUSES = JSON.stringify(jiraConfig.statuses); } diff --git a/src/cli/base.ts b/src/cli/base.ts index 95482d9b..ef4230a7 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { Command } from '@oclif/core'; import { withGitHubToken } from '../github/client.js'; +import { normalizeJiraAuthType } from '../jira/authType.js'; import { withJiraCredentials } from '../jira/client.js'; import { withLinearCredentials } from '../linear/client.js'; import { createPMProvider, withPMProvider } from '../pm/index.js'; @@ -54,8 +55,14 @@ function wrapWithCredentialScopes(fn: () => Promise): () => Promise const jiraBaseUrl = resolveJiraBaseUrl(); if (jiraEmail && jiraApiToken && jiraBaseUrl) { const prev = fn; + // Carry the injected JIRA auth mode into the credential scope so + // in-worker JIRA calls choose the correct host. Absent/unknown ⇒ 'basic'. + const jiraAuthType = normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE); fn = () => - withJiraCredentials({ email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, prev); + withJiraCredentials( + { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl, authType: jiraAuthType }, + prev, + ); } const linearApiKey = process.env.LINEAR_API_KEY; if (linearApiKey) { @@ -94,6 +101,7 @@ function synthesizeProjectFromEnv(pmType: PMType): ProjectConfig { jira: { projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', baseUrl: jiraBaseUrl ?? '', + authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), statuses: jiraStatuses ? JSON.parse(jiraStatuses) : {}, }, } as ProjectConfig; diff --git a/src/gadgets/pm/core/reportFriction.ts b/src/gadgets/pm/core/reportFriction.ts index b208c1e7..e2954c9c 100644 --- a/src/gadgets/pm/core/reportFriction.ts +++ b/src/gadgets/pm/core/reportFriction.ts @@ -5,6 +5,7 @@ import { appendQueuedFrictionReport, } from '../../../friction/sidecar.js'; import type { FrictionReport } from '../../../friction/types.js'; +import { normalizeJiraAuthType } from '../../../jira/authType.js'; import type { ProjectConfig } from '../../../types/index.js'; import { FRICTION_SIDECAR_ENV_VAR, @@ -92,6 +93,7 @@ function projectFromEnv(): ProjectConfig { jira: { projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', baseUrl: process.env.CASCADE_JIRA_BASE_URL ?? process.env.JIRA_BASE_URL ?? '', + authType: normalizeJiraAuthType(process.env.CASCADE_JIRA_AUTH_TYPE), statuses: parseJsonRecord(process.env.CASCADE_JIRA_STATUSES), }, } as ProjectConfig; diff --git a/src/jira/authType.ts b/src/jira/authType.ts new file mode 100644 index 00000000..d35e8a36 --- /dev/null +++ b/src/jira/authType.ts @@ -0,0 +1,32 @@ +/** + * JIRA authentication mode — shared type + env-var normalization. + * + * `authType` is a NON-secret connection setting (mirrors `baseUrl`, NOT a + * credential role) that selects which host in-worker JIRA REST calls route + * through: + * - `'basic'` — classic site-token mode (host = the project `baseUrl`). The + * historical default; every pre-MNG-1736 config maps here. + * - `'scoped'` — scoped gateway-token mode (host = the scoped Atlassian + * gateway). Both modes still authenticate via HTTP Basic + * `email:api_token` (confirmed live in MNG-1735) — the enum + * distinguishes the effective host, not the auth scheme. + * + * The worker/CLI credential scope carries this value across process boundaries + * via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by + * `secretBuilder.augmentProjectSecrets`). `normalizeJiraAuthType` validates a + * raw env-var string back into the `'basic' | 'scoped'` domain, falling back to + * `'basic'` for absent/unknown values so existing projects keep working + * untouched (MNG-1741; see the MNG-1735 research note on the card). + */ + +export type JiraAuthType = 'basic' | 'scoped'; + +/** + * Normalize a raw `CASCADE_JIRA_AUTH_TYPE` env-var value into the + * `'basic' | 'scoped'` domain. Absent, empty, or unrecognized values fall back + * to `'basic'` — preserving pre-MNG-1736 behavior for projects that never set + * an explicit auth mode. + */ +export function normalizeJiraAuthType(value: string | undefined | null): JiraAuthType { + return value === 'scoped' ? 'scoped' : 'basic'; +} diff --git a/src/pm/jira/integration.ts b/src/pm/jira/integration.ts index cb1856a3..e6685dca 100644 --- a/src/pm/jira/integration.ts +++ b/src/pm/jira/integration.ts @@ -64,8 +64,11 @@ export class JiraIntegration implements PMIntegration { const email = await getIntegrationCredential(projectId, 'pm', 'jira', 'email'); const apiToken = await getIntegrationCredential(projectId, 'pm', 'jira', 'api_token'); const project = await findProjectById(projectId); - const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - return withJiraCredentials({ email, apiToken, baseUrl }, fn); + const jiraConfig = project ? getJiraConfig(project) : undefined; + const baseUrl = jiraConfig?.baseUrl ?? ''; + // Carry the configured auth mode so the JIRA client can choose the + // correct host. Absent ⇒ downstream treats it as 'basic' (the default). + return withJiraCredentials({ email, apiToken, baseUrl, authType: jiraConfig?.authType }, fn); } resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index e34472e4..84b9c26d 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -170,6 +170,47 @@ describe('augmentProjectSecrets', () => { expect(secrets.CASCADE_JIRA_STATUSES).toBe(JSON.stringify(statuses)); }); + it("defaults CASCADE_JIRA_AUTH_TYPE to 'basic' when jira.authType is absent (MNG-1741)", async () => { + const project = makeProject({ + jira: { + projectKey: 'PROJ', + baseUrl: 'https://acme.atlassian.net', + }, + }); + const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); + expect(secrets.CASCADE_JIRA_AUTH_TYPE).toBe('basic'); + }); + + it("injects CASCADE_JIRA_AUTH_TYPE='scoped' when jira.authType is 'scoped' (MNG-1741)", async () => { + const project = makeProject({ + jira: { + projectKey: 'PROJ', + baseUrl: 'https://acme.atlassian.net', + authType: 'scoped', + }, + }); + const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); + expect(secrets.CASCADE_JIRA_AUTH_TYPE).toBe('scoped'); + }); + + it("injects CASCADE_JIRA_AUTH_TYPE='basic' when jira.authType is explicitly 'basic' (MNG-1741)", async () => { + const project = makeProject({ + jira: { + projectKey: 'PROJ', + baseUrl: 'https://acme.atlassian.net', + authType: 'basic', + }, + }); + const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); + expect(secrets.CASCADE_JIRA_AUTH_TYPE).toBe('basic'); + }); + + it('does NOT inject CASCADE_JIRA_AUTH_TYPE for non-JIRA projects (MNG-1741)', async () => { + const trelloProject = makeProject(); // default Trello fixture + const secrets = await augmentProjectSecrets(trelloProject, 'implementation', {} as AgentInput); + expect(secrets).not.toHaveProperty('CASCADE_JIRA_AUTH_TYPE'); + }); + it('injects CASCADE_LINEAR_* env vars when project is Linear-backed', async () => { const statuses = { backlog: 'state-bl', todo: 'state-td' }; const project = makeProject({ diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index 94947e86..78ea0d9b 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -105,6 +105,9 @@ describe('CredentialScopedCommand', () => { delete process.env.JIRA_API_TOKEN; delete process.env.JIRA_BASE_URL; delete process.env.CASCADE_JIRA_BASE_URL; + delete process.env.CASCADE_JIRA_PROJECT_KEY; + delete process.env.CASCADE_JIRA_STATUSES; + delete process.env.CASCADE_JIRA_AUTH_TYPE; vi.mocked(withJiraCredentials).mockClear(); vi.mocked(withLinearCredentials).mockClear(); }); @@ -184,16 +187,55 @@ describe('CredentialScopedCommand', () => { await cmd.run(); expect(cmd.executeCalled).toBe(true); + // authType defaults to 'basic' when CASCADE_JIRA_AUTH_TYPE is unset (MNG-1741). expect(withJiraCredentials).toHaveBeenCalledWith( { email: 'bot@example.com', apiToken: 'jira-token', baseUrl: 'https://cascade.atlassian.net', + authType: 'basic', }, expect.any(Function), ); }); + it('threads authType into withJiraCredentials from CASCADE_JIRA_AUTH_TYPE (MNG-1741)', async () => { + process.env.JIRA_EMAIL = 'bot@example.com'; + process.env.JIRA_API_TOKEN = 'jira-token'; + process.env.CASCADE_JIRA_BASE_URL = 'https://cascade.atlassian.net'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_AUTH_TYPE = 'scoped'; + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(withJiraCredentials).toHaveBeenCalledWith( + { + email: 'bot@example.com', + apiToken: 'jira-token', + baseUrl: 'https://cascade.atlassian.net', + authType: 'scoped', + }, + expect.any(Function), + ); + }); + + it("normalizes an unknown CASCADE_JIRA_AUTH_TYPE to 'basic' in the credential scope (MNG-1741)", async () => { + process.env.JIRA_EMAIL = 'bot@example.com'; + process.env.JIRA_API_TOKEN = 'jira-token'; + process.env.CASCADE_JIRA_BASE_URL = 'https://cascade.atlassian.net'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_AUTH_TYPE = 'bearer'; // not in the basic|scoped domain + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ authType: 'basic' }), + expect.any(Function), + ); + }); + it('prefers JIRA_BASE_URL over CASCADE_JIRA_BASE_URL when both are set', async () => { process.env.JIRA_BASE_URL = 'https://legacy.atlassian.net'; process.env.CASCADE_JIRA_BASE_URL = 'https://injected.atlassian.net'; @@ -264,4 +306,38 @@ describe('CredentialScopedCommand', () => { labels: { auto: 'label-auto' }, }); }); + + it('synthesises JIRA config from env vars including authType from CASCADE_JIRA_AUTH_TYPE (MNG-1741)', async () => { + process.env.CASCADE_PM_TYPE = 'jira'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_BASE_URL = 'https://acme.atlassian.net'; + process.env.CASCADE_JIRA_AUTH_TYPE = 'scoped'; + process.env.CASCADE_JIRA_STATUSES = JSON.stringify({ todo: 'To Do' }); + + const cmd = new InspectPMProviderCommand([], {} as never); + await cmd.run(); + + expect(cmd.providerConfig).toEqual({ + projectKey: 'CASCADE', + baseUrl: 'https://acme.atlassian.net', + authType: 'scoped', + statuses: { todo: 'To Do' }, + }); + }); + + it("synthesises JIRA config with authType 'basic' when CASCADE_JIRA_AUTH_TYPE is unset (MNG-1741)", async () => { + process.env.CASCADE_PM_TYPE = 'jira'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_BASE_URL = 'https://acme.atlassian.net'; + // CASCADE_JIRA_AUTH_TYPE intentionally unset → normalizes to 'basic'. + + const cmd = new InspectPMProviderCommand([], {} as never); + await cmd.run(); + + expect(cmd.providerConfig).toMatchObject({ + projectKey: 'CASCADE', + baseUrl: 'https://acme.atlassian.net', + authType: 'basic', + }); + }); }); diff --git a/tests/unit/cli/scm/create-pr-review-sidecar.test.ts b/tests/unit/cli/scm/create-pr-review-sidecar.test.ts index 2c7a58f8..4f33c61e 100644 --- a/tests/unit/cli/scm/create-pr-review-sidecar.test.ts +++ b/tests/unit/cli/scm/create-pr-review-sidecar.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { REVIEW_EVENT_POLICY_FILE } from '../../../../src/config/reviewEventPolicy.js'; + // Mock createPRReview before importing the command const mockCreatePRReview = vi.fn(); vi.mock('../../../../src/gadgets/github/core/createPRReview.js', () => ({ @@ -199,6 +201,12 @@ describe('CreatePRReviewCommand review event policy', () => { originalPolicyEnv = process.env.CASCADE_REVIEW_EVENT_POLICY; process.env.CASCADE_REVIEW_SIDECAR_PATH = sidecarPath; Reflect.deleteProperty(process.env, 'CASCADE_REVIEW_EVENT_POLICY'); + // resolveEventPolicyFromEnv() falls back to reading REVIEW_EVENT_POLICY_FILE + // (a hardcoded global /tmp path) when the env var is absent/invalid. A sibling + // suite (create-pr-review.test.ts) writes that file, and unit-core runs files in + // parallel forks (isolate: false), so a leftover file would leak 'comment-only' + // into the env-absent/invalid cases below. Remove it so these tests are hermetic. + rmSync(REVIEW_EVENT_POLICY_FILE, { force: true }); }); afterEach(() => { @@ -207,6 +215,8 @@ describe('CreatePRReviewCommand review event policy', () => { } catch { // ignore } + // Never let this suite leak the policy file to sibling suites either. + rmSync(REVIEW_EVENT_POLICY_FILE, { force: true }); if (originalSidecarEnv !== undefined) { process.env.CASCADE_REVIEW_SIDECAR_PATH = originalSidecarEnv; } else { diff --git a/tests/unit/gadgets/pm/core/reportFriction.test.ts b/tests/unit/gadgets/pm/core/reportFriction.test.ts index 7c6c1030..ec70eaca 100644 --- a/tests/unit/gadgets/pm/core/reportFriction.test.ts +++ b/tests/unit/gadgets/pm/core/reportFriction.test.ts @@ -58,6 +58,12 @@ beforeEach(() => { delete process.env.CASCADE_PROJECT_ID; delete process.env.CASCADE_PM_TYPE; delete process.env.CASCADE_PROJECT_NAME; + // Clear JIRA env-synthesis vars so each env-reconstruction test starts clean. + delete process.env.CASCADE_JIRA_PROJECT_KEY; + delete process.env.CASCADE_JIRA_BASE_URL; + delete process.env.JIRA_BASE_URL; + delete process.env.CASCADE_JIRA_STATUSES; + delete process.env.CASCADE_JIRA_AUTH_TYPE; }); describe('reportFriction', () => { @@ -249,6 +255,70 @@ describe('reportFriction', () => { rmSync(path, { force: true }); }); + it('env-synthesized JIRA config carries authType from CASCADE_JIRA_AUTH_TYPE (MNG-1741)', async () => { + // No params.project + empty SessionState → projectFromEnv() reconstruction. + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'jira-project'; + process.env.CASCADE_PM_TYPE = 'jira'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_BASE_URL = 'https://acme.atlassian.net'; + process.env.CASCADE_JIRA_AUTH_TYPE = 'scoped'; + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'CASCADE-1', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'JIRA auth mode carried', + details: 'The synthesized project must carry authType so in-worker calls use the right host.', + category: 'tooling', + severity: 'low', + }); + + // Materialization must receive the synthesized JIRA project with authType set. + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + jira: expect.objectContaining({ authType: 'scoped' }), + }), + }), + ); + rmSync(path, { force: true }); + }); + + it("env-synthesized JIRA config defaults authType to 'basic' when CASCADE_JIRA_AUTH_TYPE is unset (MNG-1741)", async () => { + const path = sidecarPath(); + process.env.CASCADE_PROJECT_ID = 'jira-project'; + process.env.CASCADE_PM_TYPE = 'jira'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + process.env.CASCADE_JIRA_BASE_URL = 'https://acme.atlassian.net'; + // CASCADE_JIRA_AUTH_TYPE intentionally unset → normalizes to 'basic'. + mockMaterializeFrictionReport.mockResolvedValue({ + status: 'filed', + reportId: 'ignored', + workItemId: 'CASCADE-2', + }); + + await reportFriction({ + sidecarPath: path, + summary: 'JIRA default auth mode', + details: 'Absent env var should synthesize basic to preserve existing projects.', + category: 'tooling', + severity: 'low', + }); + + expect(mockMaterializeFrictionReport).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + jira: expect.objectContaining({ authType: 'basic' }), + }), + }), + ); + rmSync(path, { force: true }); + }); + it('uses project from SessionState when params.project is absent (production ReportFriction wrapper path)', async () => { // The production ReportFriction.ts gadget does NOT pass params.project. // In LLMist, projectSecrets are NOT exported to process.env, so projectFromEnv() diff --git a/tests/unit/jira/authType.test.ts b/tests/unit/jira/authType.test.ts new file mode 100644 index 00000000..73a161cb --- /dev/null +++ b/tests/unit/jira/authType.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { type JiraAuthType, normalizeJiraAuthType } from '../../../src/jira/authType.js'; + +describe('normalizeJiraAuthType (MNG-1741)', () => { + it("returns 'scoped' only for the exact 'scoped' value", () => { + expect(normalizeJiraAuthType('scoped')).toBe('scoped'); + }); + + it("returns 'basic' for the explicit 'basic' value", () => { + expect(normalizeJiraAuthType('basic')).toBe('basic'); + }); + + it("falls back to 'basic' when the value is absent (undefined)", () => { + // Preserves pre-MNG-1736 behavior for projects that never set authType. + expect(normalizeJiraAuthType(undefined)).toBe('basic'); + }); + + it("falls back to 'basic' when the value is null", () => { + expect(normalizeJiraAuthType(null)).toBe('basic'); + }); + + it("falls back to 'basic' for an empty string", () => { + expect(normalizeJiraAuthType('')).toBe('basic'); + }); + + it("falls back to 'basic' for unrecognized values (no bearer/oauth expansion — MNG-1735)", () => { + expect(normalizeJiraAuthType('bearer')).toBe('basic'); + expect(normalizeJiraAuthType('oauth')).toBe('basic'); + expect(normalizeJiraAuthType('SCOPED')).toBe('basic'); // case-sensitive + expect(normalizeJiraAuthType(' scoped ')).toBe('basic'); // no trimming + }); + + it('always returns a value inside the JiraAuthType union', () => { + const result: JiraAuthType = normalizeJiraAuthType('anything'); + expect(['basic', 'scoped']).toContain(result); + }); +}); diff --git a/tests/unit/pm/jira/integration.test.ts b/tests/unit/pm/jira/integration.test.ts index 0a179d5c..7fbb4317 100644 --- a/tests/unit/pm/jira/integration.test.ts +++ b/tests/unit/pm/jira/integration.test.ts @@ -266,6 +266,38 @@ describe('JiraIntegration', () => { fn, ); }); + + it('threads authType from getJiraConfig(project)?.authType into withJiraCredentials (MNG-1741)', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('bot@example.com'); + mockGetIntegrationCredential.mockResolvedValueOnce('api-token-xxx'); + mockFindProjectById.mockResolvedValue(makeProject()); + mockGetJiraConfig.mockReturnValue(makeJiraConfig({ authType: 'scoped' })); + + const fn = vi.fn().mockResolvedValue('done'); + await integration.withCredentials('proj-1', fn); + + expect(mockWithJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'bot@example.com', + apiToken: 'api-token-xxx', + baseUrl: 'https://example.atlassian.net', + authType: 'scoped', + }), + fn, + ); + }); + + it('leaves authType undefined when jira config has none (basic downstream default)', async () => { + mockGetIntegrationCredential.mockResolvedValue('value'); + mockFindProjectById.mockResolvedValue(makeProject()); + mockGetJiraConfig.mockReturnValue(makeJiraConfig()); // no authType + + const fn = vi.fn().mockResolvedValue(undefined); + await integration.withCredentials('proj-1', fn); + + const [creds] = mockWithJiraCredentials.mock.calls[0] as [{ authType?: string }, unknown]; + expect(creds.authType).toBeUndefined(); + }); }); // ========================================================================= From fe187b7a06876503d4844337202f8bfcb1d33145 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Jul 2026 16:01:03 +0000 Subject: [PATCH 05/11] refactor(jira): route configMapper JiraAuthType through SSOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two hand-written `'basic' | 'scoped'` unions in configMapper.ts (the `JiraIntegrationConfig` interface and the `mapProjectRow` return type) with the shared `JiraAuthType` from config-schema.ts. Adding a third auth mode is now genuinely one edit — the `z.enum(...)` — instead of leaving these two annotations silently narrower than the schema. No cycle risk: config-schema.ts only imports zod. Co-Authored-By: Claude Opus 4.8 --- src/db/repositories/configMapper.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 1c2deb19..be8c2389 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -1,6 +1,7 @@ import type { EngineSettings } from '../../config/engineSettings.js'; import { REVIEW_EVENT_POLICIES, type ReviewEventPolicy } from '../../config/reviewEventPolicy.js'; import { UPDATE_CHANNELS, type UpdateChannel } from '../../config/updateChannel.js'; +import type { JiraAuthType } from '../../integrations/pm/jira/config-schema.js'; /** * Config mapper — pure transformation functions for converting DB rows into @@ -25,7 +26,7 @@ export interface JiraIntegrationConfig { projectKey: string; baseUrl: string; /** Optional JIRA auth mode (non-secret config, mirrors `baseUrl`). See jiraConfigSchema. */ - authType?: 'basic' | 'scoped'; + authType?: JiraAuthType; statuses: Record; issueTypes?: Record; customFields?: { cost?: string }; @@ -141,7 +142,7 @@ export interface ProjectConfigRaw { jira?: { projectKey: string; baseUrl: string; - authType?: 'basic' | 'scoped'; + authType?: JiraAuthType; statuses: Record; issueTypes?: Record; customFields?: { cost?: string }; From 47875b265d65314c4618303436df82df7de538c5 Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 18:28:22 +0200 Subject: [PATCH 06/11] feat(jira): route dashboard webhook management + label seeding through the effective host (MNG-1740) (#1487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(jira): route dashboard webhook management + label seeding through the effective host (MNG-1740) * fix(jira): make router-level webhook dedup list best-effort The dashboard "Create Webhook" flow lists existing JIRA webhooks at the router level (maybeCreateJiraWebhook) before jiraCreateWebhook runs. That list was not best-effort, so a scoped token lacking webhook scopes — which returns 401 on GET /rest/api/3/webhook (MNG-1735) — threw a generic "Failed to list JIRA webhooks: 401" and masked jiraCreateWebhook's actionable 401/403 scope / manual-registration message. Wrap the router-level list in the same best-effort try/catch already used inside jiraCreateWebhook so dedup is skipped on a denied list and the create's actionable error surfaces in the wizard. Adds a regression test asserting the FORBIDDEN scope message reaches the caller (and the POST create is actually attempted) when the dedup list is denied. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/api/routers/webhooks.ts | 17 +- src/api/routers/webhooks/context.ts | 1 + src/api/routers/webhooks/jira.ts | 104 +++++++- src/api/routers/webhooks/types.ts | 9 + tests/unit/api/routers/webhooks.test.ts | 50 ++++ tests/unit/api/routers/webhooks/jira.test.ts | 252 +++++++++++++++++++ 6 files changed, 425 insertions(+), 8 deletions(-) create mode 100644 tests/unit/api/routers/webhooks/jira.test.ts diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 60ee805c..f867109a 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { logger } from '../../utils/logging.js'; import { adminProcedure, router } from '../trpc.js'; import { applyOneTimeTokens, @@ -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`, ); diff --git a/src/api/routers/webhooks/context.ts b/src/api/routers/webhooks/context.ts index 61c69a8d..d94f9712 100644 --- a/src/api/routers/webhooks/context.ts +++ b/src/api/routers/webhooks/context.ts @@ -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 ?? '', diff --git a/src/api/routers/webhooks/jira.ts b/src/api/routers/webhooks/jira.ts index 6e4022c7..e475ed8f 100644 --- a/src/api/routers/webhooks/jira.ts +++ b/src/api/routers/webhooks/jira.ts @@ -1,4 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { resolveJiraApiBaseUrl } from '../../../jira/api-host.js'; +import type { JiraCredentials } from '../../../jira/types.js'; import { logger } from '../../../utils/logging.js'; import type { JiraWebhookInfo, ProjectContext } from './types.js'; @@ -6,9 +8,36 @@ function jiraAuthHeader(ctx: ProjectContext): string { return `Basic ${Buffer.from(`${ctx.jiraEmail}:${ctx.jiraApiToken}`).toString('base64')}`; } +/** + * Build a `JiraCredentials` bag from the resolved `ProjectContext` so the shared + * `resolveJiraApiBaseUrl` host resolver can pick the effective REST v3 base: + * - `basic` / absent `authType` ⇒ the tenant site URL (`ctx.jiraBaseUrl`). + * - `scoped` `authType` ⇒ the Atlassian gateway + * (`https://api.atlassian.com/ex/jira/{cloudId}`). + */ +function jiraCredentialsFromContext(ctx: ProjectContext): JiraCredentials { + return { + email: ctx.jiraEmail ?? '', + apiToken: ctx.jiraApiToken ?? '', + baseUrl: ctx.jiraBaseUrl ?? '', + authType: ctx.jiraAuthType, + }; +} + +/** + * Resolve the REST v3 base URL for this project's JIRA credentials. Every REST + * v3 call in this module routes through the returned `apiBase` so scoped API + * tokens hit the Atlassian gateway consistently. `/browse/...` UI URLs (which + * this module never builds) must stay on the site URL, never the gateway. + */ +function resolveJiraRestBase(ctx: ProjectContext): Promise { + return resolveJiraApiBaseUrl(jiraCredentialsFromContext(ctx)); +} + export async function jiraListWebhooks(ctx: ProjectContext): Promise { if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return []; - const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + const apiBase = await resolveJiraRestBase(ctx); + const response = await fetch(`${apiBase}/rest/api/3/webhook`, { headers: { Authorization: jiraAuthHeader(ctx), Accept: 'application/json', @@ -24,6 +53,36 @@ export async function jiraListWebhooks(ctx: ProjectContext): Promise ''); + // Scoped API tokens (and non-app callers) commonly get 401/403 from the + // dynamic webhook API — surface an actionable scope / manual-registration + // message rather than a raw status dump. + if (response.status === 401 || response.status === 403) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: scopedWebhookCreateMessage( + response.status, + callbackURL, + ctx.jiraBaseUrl, + errorText, + ), + }); + } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create JIRA webhook: ${response.status} ${errorText}`, @@ -94,7 +182,8 @@ export async function jiraCreateWebhook( export async function jiraDeleteWebhook(ctx: ProjectContext, webhookId: number): Promise { if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return; - const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + const apiBase = await resolveJiraRestBase(ctx); + const response = await fetch(`${apiBase}/rest/api/3/webhook`, { method: 'DELETE', headers: { Authorization: jiraAuthHeader(ctx), @@ -128,10 +217,11 @@ export async function jiraEnsureLabels(ctx: ProjectContext): Promise { if (labelsToSeed.length === 0) return []; const auth = jiraAuthHeader(ctx); + const apiBase = await resolveJiraRestBase(ctx); // Find one issue in the project const searchResponse = await fetch( - `${ctx.jiraBaseUrl}/rest/api/3/search?jql=${encodeURIComponent(`project = "${ctx.jiraProjectKey}" ORDER BY created DESC`)}&maxResults=1&fields=labels`, + `${apiBase}/rest/api/3/search?jql=${encodeURIComponent(`project = "${ctx.jiraProjectKey}" ORDER BY created DESC`)}&maxResults=1&fields=labels`, { headers: { Authorization: auth, Accept: 'application/json' }, }, @@ -158,7 +248,7 @@ export async function jiraEnsureLabels(ctx: ProjectContext): Promise { } // Add all CASCADE labels to the issue - const addResponse = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { + const addResponse = await fetch(`${apiBase}/rest/api/3/issue/${issue.key}`, { method: 'PUT', headers: { Authorization: auth, @@ -175,7 +265,7 @@ export async function jiraEnsureLabels(ctx: ProjectContext): Promise { if (!addResponse.ok) return []; // Immediately restore original labels - await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { + await fetch(`${apiBase}/rest/api/3/issue/${issue.key}`, { method: 'PUT', headers: { Authorization: auth, diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index ea81e3c0..0e7ca5cf 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -2,6 +2,8 @@ * Shared types for webhook service modules. */ +import type { JiraAuthType } from '../../../jira/authType.js'; + export interface TrelloWebhook { id: string; description: string; @@ -47,6 +49,13 @@ export interface ProjectContext { pmType: 'trello' | 'jira' | 'linear'; boardId?: string; jiraBaseUrl?: string; + /** + * JIRA authentication mode resolved from `jiraConfig?.authType` (non-secret + * connection setting, mirrors `jiraBaseUrl`). `'scoped'` routes REST v3 calls + * through the Atlassian gateway; `'basic'`/absent uses the site URL. Consumed + * by the shared `resolveJiraApiBaseUrl` host resolver in `webhooks/jira.ts`. + */ + jiraAuthType?: JiraAuthType; jiraProjectKey?: string; jiraLabels?: string[]; trelloApiKey: string; diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index ea1172c6..8e768273 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -1,3 +1,4 @@ +import { TRPCError } from '@trpc/server'; import { describe, expect, it, vi } from 'vitest'; import { createMockUser } from '../../../helpers/factories.js'; import { @@ -57,6 +58,13 @@ vi.mock('../../../../src/utils/repo.js', () => ({ }, })); +// Passthrough the JIRA REST host resolver so these tests stay hermetic (no +// jira.js import) and preserve site-URL behavior for basic/absent authType. +// Scoped-mode gateway routing is covered in webhooks/jira.test.ts. +vi.mock('../../../../src/jira/api-host.js', () => ({ + resolveJiraApiBaseUrl: vi.fn(async (creds: { baseUrl: string }) => creds.baseUrl), +})); + // Mock global fetch for Trello API calls vi.stubGlobal('fetch', mockFetch); @@ -671,6 +679,48 @@ describe('webhooksRouter', () => { expect(result.labelsEnsured).toEqual([]); }); + it('surfaces the create actionable error when the router-level dedup list is denied (scoped token)', async () => { + setupJiraProjectContext(); + + // Scoped-token scenario (MNG-1735): the token cannot even list webhooks, + // so GET /rest/api/3/webhook returns 401. The router-level dedup must be + // best-effort so jiraCreateWebhook still runs and its actionable 403 + // scope / manual-registration message surfaces — instead of a generic + // "Failed to list JIRA webhooks: 401" masking it. + // + // Fetch calls in order: + // 1. jiraListWebhooks (router duplicate check) - 401 (caught, best-effort) + // 2. jiraListWebhooks (inside jiraCreateWebhook dedup) - 401 (caught, best-effort) + // 3. jiraCreateWebhook POST - 403 (throws the friendly FORBIDDEN message) + mockFetch + .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Unauthorized; scope does not match'), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const err = await caller + .create({ + projectId: 'jira-project', + callbackBaseUrl: 'http://example.com', + jiraOnly: true, + }) + .catch((e) => e); + + // The actionable FORBIDDEN message reaches the caller (not INTERNAL_SERVER_ERROR + // from the router-level list) — this is the crux of the best-effort dedup fix. + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('manage:jira-webhook'); + expect(err.message).toContain('write:webhook:jira'); + expect(err.message).toMatch(/register the webhook manually/i); + // The POST create was actually reached: 2 denied list calls + the create. + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + it('returns Sentry manual setup info with paired project context', async () => { setupSentryProjectContext(); diff --git a/tests/unit/api/routers/webhooks/jira.test.ts b/tests/unit/api/routers/webhooks/jira.test.ts new file mode 100644 index 00000000..b5f1bbfa --- /dev/null +++ b/tests/unit/api/routers/webhooks/jira.test.ts @@ -0,0 +1,252 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the shared JIRA REST host resolver so we can assert which base URL each +// webhook function routes through, without importing the heavy jira.js client +// or hitting the /_edge/tenant_info cloudId flow. +const { mockResolveJiraApiBaseUrl, mockFetch } = vi.hoisted(() => ({ + mockResolveJiraApiBaseUrl: vi.fn(), + mockFetch: vi.fn(), +})); + +vi.mock('../../../../../src/jira/api-host.js', () => ({ + resolveJiraApiBaseUrl: mockResolveJiraApiBaseUrl, +})); + +vi.mock('../../../../../src/utils/logging.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.stubGlobal('fetch', mockFetch); + +import { + jiraCreateWebhook, + jiraDeleteWebhook, + jiraEnsureLabels, + jiraListWebhooks, +} from '../../../../../src/api/routers/webhooks/jira.js'; +import type { ProjectContext } from '../../../../../src/api/routers/webhooks/types.js'; + +const SITE = 'https://acme.atlassian.net'; +const GATEWAY = 'https://api.atlassian.com/ex/jira/cloud-abc-123'; +const CALLBACK = 'https://cascade.example.com/jira/webhook'; + +function jiraCtx(overrides: Partial = {}): ProjectContext { + return { + projectId: 'proj-1', + orgId: 'org-1', + pmType: 'jira', + jiraBaseUrl: SITE, + jiraProjectKey: 'PROJ', + jiraAuthType: 'scoped', + jiraEmail: 'bot@example.com', + jiraApiToken: 'jira-token', + jiraLabels: ['cascade-processing', 'cascade-auto'], + trelloApiKey: '', + trelloToken: '', + githubToken: '', + ...overrides, + }; +} + +const expectedAuth = `Basic ${Buffer.from('bot@example.com:jira-token').toString('base64')}`; + +describe('webhooks/jira REST host routing', () => { + beforeEach(() => { + mockResolveJiraApiBaseUrl.mockResolvedValue(GATEWAY); + }); + + describe('jiraListWebhooks', () => { + it('routes the GET through the resolved gateway base under a scoped token', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ values: [{ id: 1, url: CALLBACK }] }), + }); + + const result = await jiraListWebhooks(jiraCtx()); + + // The credentials bag forwarded to the resolver carries the scoped authType. + expect(mockResolveJiraApiBaseUrl).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: SITE, + authType: 'scoped', + email: 'bot@example.com', + apiToken: 'jira-token', + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + `${GATEWAY}/rest/api/3/webhook`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: expectedAuth }), + }), + ); + expect(result).toEqual([{ id: 1, url: CALLBACK }]); + }); + + it('routes the GET through the site base URL when authType is basic', async () => { + mockResolveJiraApiBaseUrl.mockResolvedValue(SITE); + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ values: [] }) }); + + await jiraListWebhooks(jiraCtx({ jiraAuthType: 'basic' })); + + expect(mockFetch).toHaveBeenCalledWith(`${SITE}/rest/api/3/webhook`, expect.anything()); + }); + + it('returns [] without resolving a host when credentials are missing', async () => { + const result = await jiraListWebhooks(jiraCtx({ jiraApiToken: undefined })); + + expect(result).toEqual([]); + expect(mockResolveJiraApiBaseUrl).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('jiraCreateWebhook', () => { + it('routes the POST through the resolved gateway base', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ values: [] }) }) // dedup list + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 100, url: CALLBACK }), + }); // POST + + const result = await jiraCreateWebhook(jiraCtx(), CALLBACK); + + expect(mockFetch).toHaveBeenLastCalledWith( + `${GATEWAY}/rest/api/3/webhook`, + expect.objectContaining({ method: 'POST' }), + ); + expect(result).toMatchObject({ id: 100 }); + }); + + it('surfaces a friendly scope / manual-registration message on 403', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ values: [] }) }) // dedup list + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Unauthorized; scope does not match'), + }); + + const err = await jiraCreateWebhook(jiraCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('403'); + expect(err.message).toContain('manage:jira-webhook'); + expect(err.message).toContain('write:webhook:jira'); + expect(err.message).toMatch(/register the webhook manually/i); + expect(err.message).toContain(CALLBACK); + // Site URL (not the gateway) is offered as the manual-registration target. + expect(err.message).toContain(SITE); + // The raw JIRA response is preserved for diagnostics. + expect(err.message).toContain('Unauthorized; scope does not match'); + }); + + it('surfaces the same friendly message on 401 (scope does not match)', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ values: [] }) }) // dedup list + .mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized; scope does not match'), + }); + + const err = await jiraCreateWebhook(jiraCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('FORBIDDEN'); + expect(err.message).toContain('401'); + expect(err.message).toContain('manage:jira-webhook'); + }); + + it('keeps the generic error for non-permission failures', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ values: [] }) }) // dedup list + .mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('boom'), + }); + + const err = await jiraCreateWebhook(jiraCtx(), CALLBACK).catch((e) => e); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('INTERNAL_SERVER_ERROR'); + expect(err.message).toContain('Failed to create JIRA webhook: 500'); + expect(err.message).not.toContain('manage:jira-webhook'); + }); + + it('still attempts the create (and surfaces its error) when dedup listing is denied', async () => { + // GET /webhook rejected (scope-restricted token) -> jiraListWebhooks throws. + // The create must still be attempted so its actionable error surfaces. + mockFetch + .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }) // dedup list denied + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 101, url: CALLBACK }), + }); // POST + + const result = await jiraCreateWebhook(jiraCtx(), CALLBACK); + + expect(result).toMatchObject({ id: 101 }); + // Two fetches: the failed dedup list + the successful create. + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenLastCalledWith( + `${GATEWAY}/rest/api/3/webhook`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws BAD_REQUEST without resolving a host when credentials are missing', async () => { + const err = await jiraCreateWebhook(jiraCtx({ jiraEmail: undefined }), CALLBACK).catch( + (e) => e, + ); + + expect(err).toBeInstanceOf(TRPCError); + expect(err.code).toBe('BAD_REQUEST'); + expect(mockResolveJiraApiBaseUrl).not.toHaveBeenCalled(); + }); + }); + + describe('jiraDeleteWebhook', () => { + it('routes the DELETE through the resolved gateway base', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await jiraDeleteWebhook(jiraCtx(), 55); + + expect(mockFetch).toHaveBeenCalledWith( + `${GATEWAY}/rest/api/3/webhook`, + expect.objectContaining({ method: 'DELETE' }), + ); + }); + }); + + describe('jiraEnsureLabels', () => { + it('routes the search and issue label mutations through the resolved gateway base', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ issues: [{ key: 'PROJ-1', fields: { labels: ['keep'] } }] }), + }) // JQL search + .mockResolvedValueOnce({ ok: true }) // add labels + .mockResolvedValueOnce({ ok: true }); // restore labels + + const result = await jiraEnsureLabels(jiraCtx()); + + expect(mockFetch.mock.calls[0][0]).toContain(`${GATEWAY}/rest/api/3/search`); + expect(mockFetch.mock.calls[1][0]).toBe(`${GATEWAY}/rest/api/3/issue/PROJ-1`); + expect(mockFetch.mock.calls[2][0]).toBe(`${GATEWAY}/rest/api/3/issue/PROJ-1`); + expect(result).toEqual(['cascade-processing', 'cascade-auto']); + }); + + it('returns [] without resolving a host when the project key is missing', async () => { + const result = await jiraEnsureLabels(jiraCtx({ jiraProjectKey: undefined })); + + expect(result).toEqual([]); + expect(mockResolveJiraApiBaseUrl).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); From 8f16478cc4ee2e397adf8d56af392c3564d3decb Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 18:51:29 +0200 Subject: [PATCH 07/11] fix(router): route JIRA ack/PM comments through the effective host for scoped tokens (#1488) Co-authored-by: Cascade Bot --- src/router/platformClients/credentials.ts | 11 +- src/router/platformClients/jira.ts | 50 ++++++- src/router/platformClients/types.ts | 16 +++ tests/unit/router/platformClients.test.ts | 156 ++++++++++++++++++++++ 4 files changed, 226 insertions(+), 7 deletions(-) diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index 6a602147..b511d14c 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -33,8 +33,10 @@ export async function resolveTrelloCredentials( /** * Resolve JIRA credentials for a project. - * Returns `{ email, apiToken, baseUrl, auth }` or `null` if credentials/config are missing. - * The `auth` field is the pre-computed Base64 Basic auth string. + * Returns `{ email, apiToken, baseUrl, auth, authType }` or `null` if credentials/config + * are missing. The `auth` field is the pre-computed Base64 Basic auth string; `authType` + * mirrors the project JIRA config (`'basic' | 'scoped'`, absent ⇒ `'basic'`) so the + * platform client can route scoped tokens through the Atlassian gateway. */ export async function resolveJiraCredentials( projectId: string, @@ -43,10 +45,11 @@ export async function resolveJiraCredentials( const email = await getIntegrationCredential(projectId, 'pm', 'jira', 'email'); const apiToken = await getIntegrationCredential(projectId, 'pm', 'jira', 'api_token'); const project = await findProjectById(projectId); - const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; + const jiraConfig = project ? getJiraConfig(project) : undefined; + const baseUrl = jiraConfig?.baseUrl ?? ''; if (!baseUrl) throw new Error('Missing JIRA base URL'); const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); - return { email, apiToken, baseUrl, auth }; + return { email, apiToken, baseUrl, auth, authType: jiraConfig?.authType }; } catch { return null; } diff --git a/src/router/platformClients/jira.ts b/src/router/platformClients/jira.ts index 115e1d62..65f1b566 100644 --- a/src/router/platformClients/jira.ts +++ b/src/router/platformClients/jira.ts @@ -8,7 +8,14 @@ import { markdownToAdf } from '../../pm/jira/adf.js'; import { logger } from '../../utils/logging.js'; import { resolveJiraCredentials } from './credentials.js'; -import type { PlatformCommentClient } from './types.js'; +import type { JiraCredentialsWithAuth, PlatformCommentClient } from './types.js'; + +/** + * Atlassian REST gateway origin used to route scoped API tokens. Mirrors + * `src/jira/api-host.ts` — the in-worker resolver — so router-side ack/PM + * comments hit the same host as the jira.js client under `authType: 'scoped'`. + */ +const ATLASSIAN_GATEWAY_ORIGIN = 'https://api.atlassian.com'; /** In-memory JIRA CloudId cache keyed by baseUrl */ const _jiraCloudIdCache = new Map(); @@ -30,7 +37,8 @@ export class JiraPlatformClient implements PlatformCommentClient { try { const adfBody = markdownToAdf(message); - const url = `${creds.baseUrl}/rest/api/3/issue/${issueKey}/comment`; + const base = await this._resolveEffectiveBase(creds); + const url = `${base}/rest/api/3/issue/${issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { @@ -62,7 +70,8 @@ export class JiraPlatformClient implements PlatformCommentClient { const creds = await resolveJiraCredentials(this.projectId); if (!creds) return; - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; + const base = await this._resolveEffectiveBase(creds); + const url = `${base}/rest/api/2/issue/${issueKey}/comment/${commentId}`; try { await fetch(url, { method: 'DELETE', @@ -81,6 +90,12 @@ export class JiraPlatformClient implements PlatformCommentClient { * Post a JIRA reactions-API reaction on a comment. * `target` is ignored (cloudId is resolved internally from credentials). * `reactionPayload` is `{ issueId, commentId }`. + * + * Intentionally stays on the tenant **site URL** even under `authType: 'scoped'`. + * `/rest/reactions/1.0/` is not a confirmed gateway-supported scoped-token API + * (the gateway probe returned `401 scope does not match`; MNG-1735), so this + * call must NOT route through {@link _resolveEffectiveBase}. It is best-effort: + * failures degrade to a warn log and never fail the run. */ async postReaction( _target: string, @@ -99,6 +114,7 @@ export class JiraPlatformClient implements PlatformCommentClient { const { issueId, commentId } = reactionPayload; const emojiId = 'atlassian-thought_balloon'; const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; + // Site URL only — the internal reactions API is not gateway-routable (MNG-1735). const reactionsUrl = `${creds.baseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; const reactionResponse = await fetch(reactionsUrl, { @@ -123,6 +139,34 @@ export class JiraPlatformClient implements PlatformCommentClient { } } + /** + * Resolve the effective REST base host for v3/v2 comment calls. + * + * - `authType` basic / absent ⇒ the tenant site URL (`creds.baseUrl`) — + * classic behavior, unchanged. + * - `authType === 'scoped'` ⇒ the Atlassian gateway + * `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved + * from the site `/_edge/tenant_info` endpoint via the existing per-`baseUrl` + * cache. If cloudId resolution fails, degrade to the site URL so the + * fire-and-forget comment path still attempts a post rather than throwing. + */ + private async _resolveEffectiveBase(creds: JiraCredentialsWithAuth): Promise { + if (creds.authType !== 'scoped') { + return creds.baseUrl; + } + + const cloudId = await this._getCloudId(creds.baseUrl, creds.auth); + if (!cloudId) { + logger.warn( + '[PlatformClient] Scoped JIRA cloudId unavailable, falling back to site URL:', + creds.baseUrl, + ); + return creds.baseUrl; + } + + return `${ATLASSIAN_GATEWAY_ORIGIN}/ex/jira/${cloudId}`; + } + private async _getCloudId(baseUrl: string, auth: string): Promise { const cached = _jiraCloudIdCache.get(baseUrl); if (cached) return cached; diff --git a/src/router/platformClients/types.ts b/src/router/platformClients/types.ts index bb34cc3d..e4e5ad98 100644 --- a/src/router/platformClients/types.ts +++ b/src/router/platformClients/types.ts @@ -10,6 +10,22 @@ export type { TrelloCredentials } from '../../trello/types.js'; export interface JiraCredentialsWithAuth extends JiraCredentials { /** Pre-computed Base64 Basic auth value: `email:apiToken` */ auth: string; + + /** + * Optional JIRA authentication mode carried from the project config so the + * router platform client can select the effective REST host. + * + * - `'basic'` / absent — classic site-token mode; REST calls hit the tenant + * site URL (`baseUrl`). + * - `'scoped'` — scoped gateway-token mode; v3/v2 REST comment calls route + * through the Atlassian gateway (`https://api.atlassian.com/ex/jira/{cloudId}`). + * + * Both modes still authenticate via HTTP Basic `email:api_token` — the enum + * selects the host, not the auth scheme (confirmed live in MNG-1735). + * Redeclared from {@link JiraCredentials} to document its host-selection role + * at the platform-client boundary. + */ + authType?: 'basic' | 'scoped'; } /** diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index d37ac107..9524af3c 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -31,6 +31,8 @@ vi.mock('../../../src/utils/logging.js', () => ({ import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; import { + _resetJiraCloudIdCache, + JiraPlatformClient, LinearPlatformClient, resolveGitHubHeaders, resolveJiraCredentials, @@ -90,6 +92,12 @@ const MOCK_PROJECT_WITH_JIRA = { }, }; +/** Same project but configured for scoped gateway-token auth. */ +const MOCK_PROJECT_WITH_JIRA_SCOPED = { + ...MOCK_PROJECT_WITH_JIRA, + jira: { ...MOCK_PROJECT_WITH_JIRA.jira, authType: 'scoped' as const }, +}; + beforeEach(() => { mockFetch.mockReset(); @@ -140,6 +148,22 @@ describe('resolveJiraCredentials', () => { expect(result?.auth).toBe(expected); }); + it('leaves authType undefined when the JIRA config does not set it (basic default)', async () => { + const result = await resolveJiraCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.authType).toBeUndefined(); + }); + + it('reads authType from the project JIRA config when set to scoped', async () => { + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA_SCOPED); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.authType).toBe('scoped'); + }); + it('returns null when credentials are missing', async () => { mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); @@ -426,3 +450,135 @@ describe('LinearPlatformClient', () => { }); }); }); + +// --------------------------------------------------------------------------- +// JiraPlatformClient — effective-host routing (MNG-1739) +// --------------------------------------------------------------------------- + +describe('JiraPlatformClient', () => { + beforeEach(() => { + mockLogger.info.mockReset(); + mockLogger.warn.mockReset(); + // The cloudId cache is module-level; reset it so scoped tests re-fetch tenant_info. + _resetJiraCloudIdCache(); + }); + + describe('postComment', () => { + it('basic mode: posts to the tenant site URL with a Basic auth header (no tenant_info lookup)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'jira-comment-1' }), + }); + + const client = new JiraPlatformClient('proj1'); + const result = await client.postComment('PROJ-1', 'Hello'); + + expect(result).toBe('jira-comment-1'); + // Only the comment POST fires in basic mode — no gateway/cloudId resolution. + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('scoped mode: resolves cloudId from tenant_info then posts to the Atlassian gateway URL', async () => { + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA_SCOPED); + // 1) tenant_info → cloudId, then 2) the gateway comment POST. + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-abc' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'jira-comment-9' }) }); + + const client = new JiraPlatformClient('proj1'); + const result = await client.postComment('PROJ-1', 'Hi scoped'); + + expect(result).toBe('jira-comment-9'); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [tenantUrl] = mockFetch.mock.calls[0]; + expect(tenantUrl).toBe('https://test.atlassian.net/_edge/tenant_info'); + + const [commentUrl, commentOptions] = mockFetch.mock.calls[1]; + expect(commentUrl).toBe( + 'https://api.atlassian.com/ex/jira/cloud-abc/rest/api/3/issue/PROJ-1/comment', + ); + expect(commentOptions.method).toBe('POST'); + expect(commentOptions.headers.Authorization).toMatch(/^Basic /); + }); + + it('scoped mode: falls back to the site URL when cloudId resolution fails', async () => { + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA_SCOPED); + // tenant_info fails; the comment POST still fires against the site URL. + mockFetch + .mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'nope' }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'jira-comment-fb' }) }); + + const client = new JiraPlatformClient('proj1'); + const result = await client.postComment('PROJ-1', 'fallback'); + + expect(result).toBe('jira-comment-fb'); + const [commentUrl] = mockFetch.mock.calls[1]; + expect(commentUrl).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); + }); + }); + + describe('deleteComment', () => { + it('basic mode: deletes against the tenant site URL (v2 REST)', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const client = new JiraPlatformClient('proj1'); + await client.deleteComment('PROJ-1', 'jira-comment-1'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-1/comment/jira-comment-1'); + expect(options.method).toBe('DELETE'); + }); + + it('scoped mode: deletes against the Atlassian gateway URL (v2 REST)', async () => { + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA_SCOPED); + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-abc' }) }) + .mockResolvedValueOnce({ ok: true }); + + const client = new JiraPlatformClient('proj1'); + await client.deleteComment('PROJ-1', 'jira-comment-1'); + + const [url, options] = mockFetch.mock.calls[1]; + expect(url).toBe( + 'https://api.atlassian.com/ex/jira/cloud-abc/rest/api/2/issue/PROJ-1/comment/jira-comment-1', + ); + expect(options.method).toBe('DELETE'); + }); + }); + + describe('postReaction', () => { + it('stays on the tenant site URL under scoped auth and never throws when the reactions API fails', async () => { + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA_SCOPED); + // 1) tenant_info (cloudId for the ARI), then 2) the reactions PUT fails. + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-abc' }) }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'scope does not match', + }); + + const client = new JiraPlatformClient('proj1'); + await expect( + client.postReaction('PROJ-1', { issueId: 'issue-1', commentId: 'c-1' }), + ).resolves.toBeUndefined(); + + // The reactions call must NOT be routed through the gateway. + const [reactionUrl] = mockFetch.mock.calls[1]; + expect(reactionUrl).toContain('https://test.atlassian.net/rest/reactions/1.0/'); + expect(reactionUrl).not.toContain('api.atlassian.com'); + // Degradation is logged, not thrown. + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('JIRA reactions API failed'), + 401, + expect.stringContaining('skipping'), + ); + }); + }); +}); From a0197ce2bc8e98851c08f2189dd33f45dfdbacdd Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 18:52:50 +0200 Subject: [PATCH 08/11] feat(jira): route API client through effective host for scoped tokens (#1489) Co-authored-by: Cascade Bot --- src/jira/client.ts | 100 ++++++++++++++++------ tests/unit/jira/client.test.ts | 152 +++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 24 deletions(-) diff --git a/src/jira/client.ts b/src/jira/client.ts index 16e32a28..3f9a5c34 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -8,6 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { Version3Client } from 'jira.js'; import { logger } from '../utils/logging.js'; +import { resolveJiraApiBaseUrl } from './api-host.js'; import type { JiraCredentials } from './types.js'; const jiraCredentialStore = new AsyncLocalStorage(); @@ -26,10 +27,30 @@ export function getJiraCredentials(): JiraCredentials { return scoped; } -function getClient(): Version3Client { +/** + * Build a jira.js `Version3Client` scoped to the current request's credentials, + * routed through the *effective* REST v3 host. + * + * The host is resolved via the shared `resolveJiraApiBaseUrl(creds)` resolver + * (MNG-1737): `basic` / absent `authType` keeps the classic tenant site URL + * (default behavior, unchanged), while `authType === 'scoped'` routes through + * the Atlassian gateway (`https://api.atlassian.com/ex/jira/{cloudId}`) so + * scoped API tokens reach the correct endpoint. Resolving the host requires an + * async cloudId lookup under scoped auth, so this helper is `async` — every + * `jiraClient.*` method `await`s it. + * + * Auth scheme is HTTP Basic (`email:api_token`) for BOTH modes: live probes in + * MNG-1735 confirmed scoped API tokens authenticate via Basic against the + * gateway and that Bearer/OAuth2 did NOT authenticate as the user. `authType` + * therefore selects the host, not the auth scheme — no scoped-Bearer branch is + * needed here. If a future spike shows Bearer is required for scoped, switch to + * `authentication.oauth2.accessToken` only inside a `scoped` branch. + */ +async function getClientForRequest(): Promise { const creds = getJiraCredentials(); + const host = await resolveJiraApiBaseUrl(creds); return new Version3Client({ - host: creds.baseUrl, + host, authentication: { basic: { email: creds.email, @@ -57,7 +78,7 @@ export function _resetCloudIdCache(): void { export const jiraClient = { async getIssue(issueKey: string) { logger.debug('Fetching JIRA issue', { issueKey }); - return getClient().issues.getIssue({ + return (await getClientForRequest()).issues.getIssue({ issueIdOrKey: issueKey, fields: [ 'summary', @@ -79,7 +100,7 @@ export const jiraClient = { async getIssueComments(issueKey: string) { logger.debug('Fetching JIRA issue comments', { issueKey }); - const result = await getClient().issueComments.getComments({ + const result = await (await getClientForRequest()).issueComments.getComments({ issueIdOrKey: issueKey, orderBy: '-created', }); @@ -91,7 +112,7 @@ export const jiraClient = { const fields: Record = {}; if (updates.summary) fields.summary = updates.summary; if (updates.description) fields.description = updates.description; - await getClient().issues.editIssue({ + await (await getClientForRequest()).issues.editIssue({ issueIdOrKey: issueKey, fields, }); @@ -99,7 +120,7 @@ export const jiraClient = { async addComment(issueKey: string, body: unknown): Promise { logger.debug('Adding JIRA comment', { issueKey }); - const result = await getClient().issueComments.addComment({ + const result = await (await getClientForRequest()).issueComments.addComment({ issueIdOrKey: issueKey, comment: body as Parameters[0]['comment'], }); @@ -108,7 +129,7 @@ export const jiraClient = { async updateComment(issueKey: string, commentId: string, body: unknown): Promise { logger.debug('Updating JIRA comment', { issueKey, commentId }); - await getClient().issueComments.updateComment({ + await (await getClientForRequest()).issueComments.updateComment({ issueIdOrKey: issueKey, id: commentId, body: body as Parameters[0]['body'], @@ -117,7 +138,7 @@ export const jiraClient = { async getIssueTypesForProject(projectKey: string): Promise<{ name: string; subtask: boolean }[]> { logger.debug('Fetching JIRA issue types for project', { projectKey }); - const project = await getClient().projects.getProject({ + const project = await (await getClientForRequest()).projects.getProject({ projectIdOrKey: projectKey, }); const types = (project.issueTypes ?? []) as { name?: string; subtask?: boolean }[]; @@ -129,7 +150,7 @@ export const jiraClient = { async searchProjects(): Promise> { logger.debug('Searching JIRA projects'); - const result = await getClient().projects.searchProjects({ maxResults: 100 }); + const result = await (await getClientForRequest()).projects.searchProjects({ maxResults: 100 }); const values = (result.values ?? []) as Array<{ key?: string; name?: string }>; return values.map((p) => ({ key: p.key ?? '', @@ -139,7 +160,7 @@ export const jiraClient = { async getProjectStatuses(projectKey: string): Promise> { logger.debug('Fetching JIRA project statuses', { projectKey }); - const result = await getClient().projects.getAllStatuses({ + const result = await (await getClientForRequest()).projects.getAllStatuses({ projectIdOrKey: projectKey, }); // getAllStatuses returns issueType-grouped statuses; flatten and deduplicate @@ -161,7 +182,7 @@ export const jiraClient = { async getFields(): Promise> { logger.debug('Fetching JIRA fields'); - const fields = await getClient().issueFields.getFields(); + const fields = await (await getClientForRequest()).issueFields.getFields(); return (fields as Array<{ id?: string; name?: string; custom?: boolean }>).map((f) => ({ id: f.id ?? '', name: f.name ?? '', @@ -174,7 +195,7 @@ export const jiraClient = { project: (fields.project as { key?: string })?.key, }); try { - return await getClient().issues.createIssue({ + return await (await getClientForRequest()).issues.createIssue({ fields: fields as Parameters[0]['fields'], }); } catch (error: unknown) { @@ -197,7 +218,7 @@ export const jiraClient = { async transitionIssue(issueKey: string, transitionId: string) { logger.debug('Transitioning JIRA issue', { issueKey, transitionId }); - await getClient().issues.doTransition({ + await (await getClientForRequest()).issues.doTransition({ issueIdOrKey: issueKey, transition: { id: transitionId }, }); @@ -205,20 +226,22 @@ export const jiraClient = { async getTransitions(issueKey: string) { logger.debug('Fetching JIRA transitions', { issueKey }); - const result = await getClient().issues.getTransitions({ issueIdOrKey: issueKey }); + const result = await (await getClientForRequest()).issues.getTransitions({ + issueIdOrKey: issueKey, + }); return result.transitions ?? []; }, async updateLabels(issueKey: string, labels: string[]) { logger.debug('Updating JIRA issue labels', { issueKey, labels }); - await getClient().issues.editIssue({ + await (await getClientForRequest()).issues.editIssue({ issueIdOrKey: issueKey, fields: { labels }, }); }, async getIssueLabels(issueKey: string): Promise { - const issue = await getClient().issues.getIssue({ + const issue = await (await getClientForRequest()).issues.getIssue({ issueIdOrKey: issueKey, fields: ['labels'], }); @@ -230,7 +253,7 @@ export const jiraClient = { fields: string[] = ['summary', 'status', 'labels', 'created', 'updated'], ) { logger.debug('Searching JIRA issues', { jql }); - const result = await getClient().issueSearch.searchForIssuesUsingJql({ + const result = await (await getClientForRequest()).issueSearch.searchForIssuesUsingJql({ jql, fields, }); @@ -238,7 +261,7 @@ export const jiraClient = { }, async getCustomFieldValue(issueKey: string, fieldId: string): Promise { - const issue = await getClient().issues.getIssue({ + const issue = await (await getClientForRequest()).issues.getIssue({ issueIdOrKey: issueKey, fields: [fieldId], }); @@ -246,7 +269,7 @@ export const jiraClient = { }, async updateCustomField(issueKey: string, fieldId: string, value: unknown) { - await getClient().issues.editIssue({ + await (await getClientForRequest()).issues.editIssue({ issueIdOrKey: issueKey, fields: { [fieldId]: value }, }); @@ -254,13 +277,19 @@ export const jiraClient = { async getMyself() { logger.debug('Fetching authenticated JIRA user'); - return getClient().myself.getCurrentUser(); + return (await getClientForRequest()).myself.getCurrentUser(); }, /** * Resolve the tenant cloudId from the site `/_edge/tenant_info` endpoint, * caching the result per `baseUrl`. * + * This ALWAYS hits the tenant site URL (`${creds.baseUrl}/_edge/tenant_info`) + * regardless of `authType` — it is how we *discover* the cloudId that scoped + * requests route through, so it must never itself be routed through the + * gateway. The scoped `/_edge/tenant_info` probe with a scoped Basic token is + * confirmed working live (MNG-1735). + * * @param explicitCreds - Optional credentials to use instead of the * `withJiraCredentials()` scope. Pass these when resolving outside an * AsyncLocalStorage scope (e.g. the shared `resolveJiraApiBaseUrl` host @@ -287,9 +316,26 @@ export const jiraClient = { return data.cloudId; }, + /** + * Add a best-effort "eyes"-style reaction to a JIRA comment. + * + * Reactions use the internal `/rest/reactions/1.0/` API which lives ONLY on + * the tenant site URL — it is not exposed through the scoped Atlassian + * gateway, and live probes (MNG-1735) found no supported scoped-token + * reactions path. Under scoped auth we therefore degrade quietly: log one + * line and skip. This is a cosmetic acknowledgment and must never fail the + * run. Basic / absent auth keeps the classic site-URL behavior unchanged. + */ async addCommentReaction(issueId: string, commentId: string, emojiId: string): Promise { logger.debug('Adding reaction to JIRA comment', { issueId, commentId, emojiId }); const creds = getJiraCredentials(); + if (creds.authType === 'scoped') { + logger.info( + 'JIRA reactions are unavailable under scoped API tokens; skipping comment reaction', + { issueId, commentId }, + ); + return; + } const cloudId = await jiraClient.getCloudId(); const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; const response = await fetch( @@ -309,7 +355,7 @@ export const jiraClient = { async deleteIssue(issueKey: string) { logger.debug('Deleting JIRA issue', { issueKey }); - await getClient().issues.deleteIssue({ issueIdOrKey: issueKey }); + await (await getClientForRequest()).issues.deleteIssue({ issueIdOrKey: issueKey }); }, /** @@ -319,6 +365,12 @@ export const jiraClient = { * credentials. Returns `null` on any failure so the caller pipeline never * crashes. * + * Host note: attachment `content` URLs are ABSOLUTE tenant site URLs handed + * back by the JIRA API (e.g. `${baseUrl}/secure/attachment/...`), so this + * fetch is intentionally NOT routed through `resolveJiraApiBaseUrl` / the + * scoped gateway — the absolute URL already points at the correct host and + * Basic auth downloads it directly under both basic and scoped tokens. + * * @param url - The JIRA attachment URL to download. * @returns `{ buffer, mimeType }` on success, `null` on failure. */ @@ -331,7 +383,7 @@ export const jiraClient = { async addAttachmentFile(issueKey: string, buffer: Buffer, filename: string) { logger.debug('Adding JIRA attachment', { issueKey, filename }); - await getClient().issueAttachments.addAttachment({ + await (await getClientForRequest()).issueAttachments.addAttachment({ issueIdOrKey: issueKey, attachment: { filename, @@ -342,7 +394,7 @@ export const jiraClient = { async addRemoteLink(issueKey: string, url: string, title: string): Promise { logger.debug('Adding JIRA remote link', { issueKey, url, title }); - await getClient().issueRemoteLinks.createOrUpdateRemoteIssueLink({ + await (await getClientForRequest()).issueRemoteLinks.createOrUpdateRemoteIssueLink({ issueIdOrKey: issueKey, globalId: url, relationship: 'Pull Request', @@ -364,7 +416,7 @@ export const jiraClient = { ): Promise<{ id: string; name: string }> { logger.debug('Creating JIRA custom field', { name, type, searcherKey }); try { - const result = await getClient().issueFields.createCustomField({ + const result = await (await getClientForRequest()).issueFields.createCustomField({ name, type, // searcherKey enables JQL searchability for this field (e.g. `"Cost" > 100`). diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index d09be0b0..c8567f08 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -69,6 +69,7 @@ vi.mock('jira.js', () => ({ })), })); +import { Version3Client } from 'jira.js'; import { _resetCloudIdCache, getJiraCredentials, @@ -943,6 +944,157 @@ describe('jiraClient', () => { }); }); + // ===== Effective-host routing (MNG-1738) ===== + // + // NOTE: These blocks intentionally do NOT call vi.restoreAllMocks() — that + // would wipe the Version3Client mock implementation from vi.mock() (see the + // file-level afterEach note). They rely on the global clearMocks: true to + // clear call history between tests, and must run BEFORE the downloadAttachment + // block (the only block that restores mocks). + + describe('effective host routing', () => { + describe('basic / absent authType (default behavior unchanged)', () => { + it('constructs Version3Client with the tenant site host and Basic auth', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + mockIssues.getIssue.mockResolvedValue({ key: 'TEST-1', fields: {} }); + + await withJiraCredentials(creds, () => jiraClient.getIssue('TEST-1')); + + expect(Version3Client).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'https://jira.example.com', + authentication: { + basic: { email: 'bot@example.com', apiToken: 'jira-token' }, + }, + }), + ); + // Site host needs no cloudId lookup — no network call is made. + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('routes to the site host when authType is explicitly basic', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + mockIssues.getIssue.mockResolvedValue({ key: 'TEST-1', fields: {} }); + + await withJiraCredentials({ ...creds, authType: 'basic' }, () => + jiraClient.getIssue('TEST-1'), + ); + + expect(Version3Client).toHaveBeenCalledWith( + expect.objectContaining({ host: 'https://jira.example.com' }), + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('scoped authType', () => { + const scopedCreds = { ...creds, authType: 'scoped' as const }; + + it('constructs Version3Client with the Atlassian gateway host (still Basic auth)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-scoped-1' }), { status: 200 }), + ); + mockIssues.getIssue.mockResolvedValue({ key: 'TEST-1', fields: {} }); + + await withJiraCredentials(scopedCreds, () => jiraClient.getIssue('TEST-1')); + + expect(Version3Client).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'https://api.atlassian.com/ex/jira/cloud-scoped-1', + // Auth stays Basic (email:api_token) — host changes, not the scheme. + authentication: { + basic: { email: 'bot@example.com', apiToken: 'jira-token' }, + }, + }), + ); + }); + + it('resolves the gateway host from the site /_edge/tenant_info endpoint', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-scoped-2' }), { status: 200 }), + ); + mockIssues.editIssue.mockResolvedValue(undefined); + + await withJiraCredentials(scopedCreds, () => + jiraClient.updateIssue('TEST-1', { summary: 'New Title' }), + ); + + // cloudId discovery still hits the tenant SITE URL, not the gateway. + expect(fetchSpy).toHaveBeenCalledWith( + 'https://jira.example.com/_edge/tenant_info', + expect.objectContaining({ headers: { Authorization: expectedAuth } }), + ); + }); + + it('routes createIssue through the gateway host under scoped auth', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-scoped-3' }), { status: 200 }), + ); + mockIssues.createIssue.mockResolvedValue({ id: '10001', key: 'TEST-2' }); + + await withJiraCredentials(scopedCreds, () => + jiraClient.createIssue({ + project: { key: 'TEST' }, + summary: 'New Issue', + issuetype: { name: 'Task' }, + }), + ); + + expect(Version3Client).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'https://api.atlassian.com/ex/jira/cloud-scoped-3', + }), + ); + }); + }); + }); + + // ===== getCloudId site-host invariant (MNG-1738) ===== + + describe('getCloudId site-host invariant', () => { + it('hits the site /_edge/tenant_info endpoint even under scoped authType', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ cloudId: 'cloud-abc-123' }), { status: 200 }), + ); + + const result = await withJiraCredentials({ ...creds, authType: 'scoped' }, () => + jiraClient.getCloudId(), + ); + + expect(result).toBe('cloud-abc-123'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://jira.example.com/_edge/tenant_info', + expect.objectContaining({ headers: { Authorization: expectedAuth } }), + ); + }); + }); + + // ===== addCommentReaction scoped degradation (MNG-1738) ===== + + describe('addCommentReaction scoped degradation', () => { + it('skips quietly (no fetch, no throw) and logs under scoped authType', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const { logger } = await import('../../../src/utils/logging.js'); + + await expect( + withJiraCredentials({ ...creds, authType: 'scoped' }, () => + jiraClient.addCommentReaction('10001', '20001', 'atlassian-thought_balloon'), + ), + ).resolves.toBeUndefined(); + + // Reactions are unavailable via the scoped gateway — no network call at all. + expect(fetchSpy).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'JIRA reactions are unavailable under scoped API tokens; skipping comment reaction', + { issueId: '10001', commentId: '20001' }, + ); + }); + }); + // ===== downloadAttachment ===== describe('downloadAttachment', () => { From eb14b90c7b5306e3208a5a59204a6eae4cf5d184 Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 19:18:24 +0200 Subject: [PATCH 09/11] feat(jira): thread authType through discovery + verification endpoints (#1490) Co-authored-by: Cascade Bot --- src/api/routers/integrationsDiscovery.ts | 37 +++++- src/integrations/pm/jira/manifest.ts | 59 +++++++-- .../api/routers/integrationsDiscovery.test.ts | 124 +++++++++++++++++- .../manifest-config-to-credentials.test.ts | 50 +++++++ tests/unit/pm/jira/manifest-discovery.test.ts | 72 ++++++++++ tests/unit/pm/jira/manifest-mutations.test.ts | 39 ++++++ 6 files changed, 359 insertions(+), 22 deletions(-) diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 20d02e33..138dd7fd 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -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({ @@ -63,10 +71,20 @@ async function withTrelloCreds( async function withJiraCreds( input: z.infer, label: string, - fn: (creds: { email: string; apiToken: string; baseUrl: string }) => Promise, + fn: (creds: { + email: string; + apiToken: string; + baseUrl: string; + authType?: 'basic' | 'scoped'; + }) => Promise, ): Promise { 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, + }), ); } @@ -205,14 +223,21 @@ export const integrationsDiscoveryRouter = router({ 'jira', 'api_token', ); - const baseUrl = (integration.config as Record | null)?.baseUrl as - | string - | undefined; + const config = integration.config as Record | 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), diff --git a/src/integrations/pm/jira/manifest.ts b/src/integrations/pm/jira/manifest.ts index d1249511..46b72fe6 100644 --- a/src/integrations/pm/jira/manifest.ts +++ b/src/integrations/pm/jira/manifest.ts @@ -30,7 +30,7 @@ import { JiraReadyToProcessLabelTrigger } from '../../../triggers/jira/label-add import { JiraStatusChangedTrigger } from '../../../triggers/jira/status-changed.js'; import { makeHmacSha256Verifier } from '../_shared/webhook-verifier.js'; import type { PMProviderManifest } from '../manifest.js'; -import { jiraConfigSchema } from './config-schema.js'; +import { type JiraAuthType, jiraConfigSchema } from './config-schema.js'; /** * Coerce a JIRA status name to a CASCADE-canonical category. JIRA @@ -50,6 +50,20 @@ function classifyJiraStatus( return 'unknown'; } +/** + * Narrow a raw `auth_type` value to the {@link JiraAuthType} union. Accepts + * `unknown` so it can guard both the discovery credentials bag (a + * `Record`, where the value is a plain string or `undefined`) + * and the persisted integration config (where `authType` is untyped JSON). + * Anything other than the two recognized modes returns `undefined`, which the + * JIRA client + host resolver (`resolveJiraApiBaseUrl`) treat as `'basic'` — + * the historical default. This keeps unknown / absent values from ever + * selecting the scoped gateway host. + */ +function coerceJiraAuthType(value: unknown): JiraAuthType | undefined { + return value === 'basic' || value === 'scoped' ? value : undefined; +} + const jiraIntegration = new JiraIntegration(); export const jiraManifest: PMProviderManifest = { @@ -101,7 +115,11 @@ export const jiraManifest: PMProviderManifest = { const email = credentials.email ?? ''; const apiToken = credentials.api_token ?? ''; const baseUrl = credentials.base_url ?? ''; - return withJiraCredentials({ email, apiToken, baseUrl }, async () => { + // Carry the configured auth mode so the JIRA client routes the + // createCustomField call through the correct host (site vs. scoped + // gateway). Absent ⇒ downstream treats it as 'basic'. + const authType = coerceJiraAuthType(credentials.auth_type); + return withJiraCredentials({ email, apiToken, baseUrl, authType }, async () => { const result = await jiraClient.createCustomField( name, 'com.atlassian.jira.plugin.system.customfieldtypes:float', @@ -134,19 +152,29 @@ export const jiraManifest: PMProviderManifest = { }, /** - * JIRA's cloud tenant URL is a non-secret connection field stored on - * `project_integrations.config.baseUrl`, not `project_credentials`. The - * pm-discovery resolver invokes this hook on the projectId path to - * promote the URL into the credentials bag the `createDiscoveryProvider` - * factory below consumes. Without it, edit-mode re-verification in the - * wizard constructs `new Version3Client({ host: '' })` and throws - * "Couldn't parse the host URL" (prod incident 2026-04-24). + * JIRA's cloud tenant URL and auth mode are non-secret connection fields + * stored on `project_integrations.config` (`baseUrl` / `authType`), not + * `project_credentials`. The pm-discovery resolver invokes this hook on the + * projectId path to promote them into the credentials bag the + * `createDiscoveryProvider` factory below consumes. Without `base_url`, + * edit-mode re-verification in the wizard constructs + * `new Version3Client({ host: '' })` and throws "Couldn't parse the host + * URL" (prod incident 2026-04-24). Without `auth_type`, scoped projects + * re-verify against the classic site host instead of the Atlassian gateway + * (MNG-1743). */ configToCredentials: (config: unknown): Record => { if (!config || typeof config !== 'object') return {}; + const promoted: Record = {}; const baseUrl = (config as { baseUrl?: unknown }).baseUrl; - if (typeof baseUrl !== 'string' || baseUrl.length === 0) return {}; - return { base_url: baseUrl }; + if (typeof baseUrl === 'string' && baseUrl.length > 0) { + promoted.base_url = baseUrl; + } + const authType = coerceJiraAuthType((config as { authType?: unknown }).authType); + if (authType) { + promoted.auth_type = authType; + } + return promoted; }, configSchema: jiraConfigSchema, @@ -195,9 +223,16 @@ export const jiraManifest: PMProviderManifest = { const email = creds.email ?? ''; const apiToken = creds.api_token ?? ''; const baseUrl = creds.base_url ?? ''; + // Carry the configured auth mode so wizard-time / edit-mode discovery + // (currentUser / projects / states / customFields) routes through the + // correct host — the classic site URL for basic, the Atlassian gateway + // for scoped. `configToCredentials` promotes `config.authType` into + // `auth_type` on the projectId (edit-mode) path; the raw-creds + // (first-time setup) path receives it straight from the wizard. + const authType = coerceJiraAuthType(creds.auth_type); const runWithCreds = (fn: () => Promise): Promise => - withJiraCredentials({ email, apiToken, baseUrl }, fn); + withJiraCredentials({ email, apiToken, baseUrl, authType }, fn); const provider: Pick = { type: 'jira', diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 240ff2f4..40ea204a 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -9,6 +9,7 @@ const { mockTrelloGetBoardLabels, mockTrelloGetBoardCustomFields, mockTrelloCreateBoardCustomField, + mockWithJiraCredentials, mockJiraGetMyself, mockJiraSearchProjects, mockJiraGetProjectStatuses, @@ -32,6 +33,7 @@ const { mockTrelloGetBoardLabels: vi.fn(), mockTrelloGetBoardCustomFields: vi.fn(), mockTrelloCreateBoardCustomField: vi.fn(), + mockWithJiraCredentials: vi.fn((_creds: unknown, cb: () => unknown) => cb()), mockJiraGetMyself: vi.fn(), mockJiraSearchProjects: vi.fn(), mockJiraGetProjectStatuses: vi.fn(), @@ -66,10 +68,7 @@ vi.mock('../../../../src/trello/client.js', () => ({ })); vi.mock('../../../../src/jira/client.js', () => ({ - withJiraCredentials: (...args: unknown[]) => { - const cb = args[1] as () => unknown; - return cb(); - }, + withJiraCredentials: mockWithJiraCredentials, jiraClient: { getMyself: mockJiraGetMyself, searchProjects: mockJiraSearchProjects, @@ -319,6 +318,56 @@ describe('integrationsDiscoveryRouter', () => { expect(mockJiraGetIssueTypesForProject).toHaveBeenCalledWith('PROJ'); }); + // MNG-1743: authType from the wizard form is threaded into + // withJiraCredentials so raw-creds verification routes through the + // correct host (site URL for basic, Atlassian gateway for scoped). + it('threads authType into withJiraCredentials (raw-creds path)', async () => { + mockJiraGetProjectStatuses.mockResolvedValue([]); + mockJiraGetIssueTypesForProject.mockResolvedValue([]); + mockJiraGetFields.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.jiraProjectDetails({ + ...jiraCredsInput, + authType: 'scoped', + projectKey: 'PROJ', + }); + + expect(mockWithJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: jiraCredsInput.email, + apiToken: jiraCredsInput.apiToken, + baseUrl: jiraCredsInput.baseUrl, + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it('passes authType undefined when omitted (raw-creds path)', async () => { + mockJiraGetProjectStatuses.mockResolvedValue([]); + mockJiraGetIssueTypesForProject.mockResolvedValue([]); + mockJiraGetFields.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }); + + const call = mockWithJiraCredentials.mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); + + it('rejects an unknown authType value at the input boundary', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + // `authType` is a `basic | scoped` enum — cast past the compile-time + // type to prove the runtime Zod enum rejects anything else. + const badInput = { + ...jiraCredsInput, + authType: 'bearer', + projectKey: 'PROJ', + } as unknown as Parameters[0]; + await expect(caller.jiraProjectDetails(badInput)).rejects.toThrow(); + }); + it('rejects lowercase projectKey', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect( @@ -446,6 +495,73 @@ describe('integrationsDiscoveryRouter', () => { ]); }); + // MNG-1743: resolve the saved auth mode from integration config and + // thread it into withJiraCredentials so edit-mode re-verification for + // scoped projects routes through the Atlassian gateway host. + it('resolves authType from saved config and threads it into withJiraCredentials', async () => { + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('stored@example.com') + .mockResolvedValueOnce('stored-token'); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ + provider: 'jira', + config: { baseUrl: 'https://myorg.atlassian.net', authType: 'scoped' }, + }); + mockJiraGetProjectStatuses.mockResolvedValue([]); + mockJiraGetIssueTypesForProject.mockResolvedValue([]); + mockJiraGetFields.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' }); + + expect(mockWithJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'stored@example.com', + apiToken: 'stored-token', + baseUrl: 'https://myorg.atlassian.net', + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it('passes authType undefined when saved config has no authType (stored-creds path)', async () => { + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('stored@example.com') + .mockResolvedValueOnce('stored-token'); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ + provider: 'jira', + config: { baseUrl: 'https://myorg.atlassian.net' }, + }); + mockJiraGetProjectStatuses.mockResolvedValue([]); + mockJiraGetIssueTypesForProject.mockResolvedValue([]); + mockJiraGetFields.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' }); + + const call = mockWithJiraCredentials.mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); + + it('ignores an unrecognized authType stored in config (stored-creds path)', async () => { + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('stored@example.com') + .mockResolvedValueOnce('stored-token'); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ + provider: 'jira', + config: { baseUrl: 'https://myorg.atlassian.net', authType: 'bearer' }, + }); + mockJiraGetProjectStatuses.mockResolvedValue([]); + mockJiraGetIssueTypesForProject.mockResolvedValue([]); + mockJiraGetFields.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.jiraProjectDetailsByProject({ projectId: 'proj-1', projectKey: 'PROJ' }); + + const call = mockWithJiraCredentials.mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); + it('throws NOT_FOUND when credentials are missing', async () => { mockGetIntegrationCredentialOrNull.mockResolvedValue(null); mockGetIntegrationByProjectAndCategory.mockResolvedValue({ diff --git a/tests/unit/pm/jira/manifest-config-to-credentials.test.ts b/tests/unit/pm/jira/manifest-config-to-credentials.test.ts index a212ad58..29a3ea63 100644 --- a/tests/unit/pm/jira/manifest-config-to-credentials.test.ts +++ b/tests/unit/pm/jira/manifest-config-to-credentials.test.ts @@ -70,3 +70,53 @@ describe('jiraManifest.configToCredentials', () => { ).toEqual({ base_url: 'https://acme.atlassian.net' }); }); }); + +/** + * MNG-1743: the hook also promotes `config.authType → auth_type` so the + * projectId (edit-mode) path of `pm.discovery.*` re-verifies scoped JIRA + * projects against the Atlassian gateway host instead of the classic site URL. + */ +describe('jiraManifest.configToCredentials — authType promotion (MNG-1743)', () => { + let hook: NonNullable; + + beforeAll(() => { + const declared = jiraManifest.configToCredentials; + if (!declared) { + throw new Error('jiraManifest.configToCredentials must be declared'); + } + hook = declared; + }); + + it("promotes authType: 'scoped' into auth_type alongside base_url", () => { + expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'scoped' })).toEqual({ + base_url: 'https://acme.atlassian.net', + auth_type: 'scoped', + }); + }); + + it("promotes authType: 'basic' into auth_type alongside base_url", () => { + expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'basic' })).toEqual({ + base_url: 'https://acme.atlassian.net', + auth_type: 'basic', + }); + }); + + it('promotes authType even when baseUrl is absent', () => { + expect(hook({ authType: 'scoped' })).toEqual({ auth_type: 'scoped' }); + }); + + it('omits auth_type when authType is absent (backward compat)', () => { + expect(hook({ baseUrl: 'https://acme.atlassian.net' })).toEqual({ + base_url: 'https://acme.atlassian.net', + }); + }); + + it('omits auth_type for unknown / invalid authType values', () => { + expect(hook({ baseUrl: 'https://acme.atlassian.net', authType: 'bearer' })).toEqual({ + base_url: 'https://acme.atlassian.net', + }); + expect(hook({ authType: '' })).toEqual({}); + expect(hook({ authType: 123 })).toEqual({}); + expect(hook({ authType: null })).toEqual({}); + }); +}); diff --git a/tests/unit/pm/jira/manifest-discovery.test.ts b/tests/unit/pm/jira/manifest-discovery.test.ts index bf176371..2d697ef3 100644 --- a/tests/unit/pm/jira/manifest-discovery.test.ts +++ b/tests/unit/pm/jira/manifest-discovery.test.ts @@ -49,6 +49,7 @@ vi.mock('../../../../src/jira/client.js', () => { }); import { jiraManifest } from '../../../../src/integrations/pm/jira/manifest.js'; +import { withJiraCredentials } from '../../../../src/jira/client.js'; describe('jiraManifest.discoveryCapabilities', () => { it('declares projects, states, labels, customFields, currentUser', () => { @@ -131,3 +132,74 @@ describe('jiraManifest.discover via createDiscoveryProvider', () => { }); }); }); + +/** + * MNG-1743: the discovery factory reads `creds.auth_type` and threads it into + * `withJiraCredentials` so wizard-time / edit-mode verification calls route + * through the correct host (site URL for basic, Atlassian gateway for scoped). + */ +describe('jiraManifest.createDiscoveryProvider threads authType (MNG-1743)', () => { + function makeProviderWith(credentials: Record) { + if (!jiraManifest.createDiscoveryProvider) { + throw new Error('createDiscoveryProvider missing on jiraManifest'); + } + return jiraManifest.createDiscoveryProvider({ credentials }); + } + + it("forwards auth_type: 'scoped' into withJiraCredentials", async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', + }); + await provider.discover?.('projects', {}); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'user@example.com', + apiToken: 'tok', + baseUrl: 'https://example.atlassian.net', + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it("forwards auth_type: 'basic' into withJiraCredentials", async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'basic', + }); + await provider.discover?.('currentUser', {}); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ authType: 'basic' }), + expect.any(Function), + ); + }); + + it('passes authType undefined when auth_type is absent (defaults to basic downstream)', async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + }); + await provider.discover?.('projects', {}); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect(call).toBeDefined(); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); + + it('passes authType undefined for an unrecognized auth_type value', async () => { + const provider = makeProviderWith({ + email: 'user@example.com', + api_token: 'tok', + base_url: 'https://example.atlassian.net', + auth_type: 'bearer', + }); + await provider.discover?.('projects', {}); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); +}); diff --git a/tests/unit/pm/jira/manifest-mutations.test.ts b/tests/unit/pm/jira/manifest-mutations.test.ts index b04569eb..d08ea52a 100644 --- a/tests/unit/pm/jira/manifest-mutations.test.ts +++ b/tests/unit/pm/jira/manifest-mutations.test.ts @@ -23,6 +23,7 @@ vi.mock('../../../../src/jira/client.js', () => ({ })); import { jiraManifest } from '../../../../src/integrations/pm/jira/manifest.js'; +import { withJiraCredentials } from '../../../../src/jira/client.js'; describe('jiraManifest.createCustomField (plan 010/1)', () => { it('is declared', () => { @@ -40,6 +41,44 @@ describe('jiraManifest.createCustomField (plan 010/1)', () => { expect(result).toMatchObject({ name: 'Cost' }); expect(result.type).toBeTruthy(); }); + + // MNG-1743: thread the configured auth mode into withJiraCredentials so the + // createCustomField call routes through the correct host under scoped auth. + it("threads auth_type: 'scoped' from credentials into withJiraCredentials", async () => { + const hook = jiraManifest.createCustomField; + if (!hook) throw new Error('createCustomField should be defined'); + await hook({ + credentials: { + email: 'a@b.com', + api_token: 't', + base_url: 'https://x.atlassian.net', + auth_type: 'scoped', + }, + containerId: 'CASC', + name: 'Cost', + }); + expect(withJiraCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'a@b.com', + apiToken: 't', + baseUrl: 'https://x.atlassian.net', + authType: 'scoped', + }), + expect.any(Function), + ); + }); + + it('passes authType undefined when auth_type is absent', async () => { + const hook = jiraManifest.createCustomField; + if (!hook) throw new Error('createCustomField should be defined'); + await hook({ + credentials: { email: 'a@b.com', api_token: 't', base_url: 'https://x.atlassian.net' }, + containerId: 'CASC', + name: 'Cost', + }); + const call = vi.mocked(withJiraCredentials).mock.calls.at(-1); + expect((call?.[0] as { authType?: string }).authType).toBeUndefined(); + }); }); describe('jiraManifest does NOT declare createLabel (free-form labels)', () => { From e7799416bc75291148b414f57246df993f27464a Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 19:47:46 +0200 Subject: [PATCH 10/11] feat(jira-wizard): add API-token-with-scopes auth-type selector (MNG-1744) (#1491) Co-authored-by: Cascade Bot --- .../web/jira-credentials-auth-type.test.ts | 111 +++++++++++++++++ tests/unit/web/pm-wizard-hooks.test.ts | 64 ++++++++++ tests/unit/web/pm-wizard-state.test.ts | 44 +++++++ .../projects/pm-providers/jira/auth.ts | 4 + .../projects/pm-providers/jira/hooks.ts | 6 + .../projects/pm-providers/jira/state.ts | 27 ++++ .../projects/pm-providers/jira/wizard.ts | 116 +++++++++++++++--- .../components/projects/pm-wizard-state.ts | 2 + 8 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 tests/unit/web/jira-credentials-auth-type.test.ts diff --git a/tests/unit/web/jira-credentials-auth-type.test.ts b/tests/unit/web/jira-credentials-auth-type.test.ts new file mode 100644 index 00000000..9569c8d1 --- /dev/null +++ b/tests/unit/web/jira-credentials-auth-type.test.ts @@ -0,0 +1,111 @@ +/** + * JIRA credentials step — API-token-with-scopes selector (MNG-1744). + * + * The JiraCredentialsAdapter (first step of the JIRA wizard) renders a + * basic/scoped auth-type selector above the credential inputs. The choice + * drives host routing (site URL for `basic`, api.atlassian.com gateway for + * `scoped`) and is threaded into the verify credential bag as `auth_type`. + * + * These tests render the adapter to static markup (SSR-safe shadcn + * primitives) and assert the selector + helper text, and verify the + * segmented control is wired to `SET_JIRA_AUTH_TYPE`. + */ + +import { createElement, isValidElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; +import type { ProviderWizardStepProps } from '../../../web/src/components/projects/pm-providers/types.js'; +import type { + WizardAction, + WizardState, +} from '../../../web/src/components/projects/pm-wizard-state.js'; +import { createInitialState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +const CredentialsComponent = jiraProviderWizard.steps.find((s) => s.id === 'jira-credentials') + ?.Component as (props: ProviderWizardStepProps) => ReactElement; + +function jiraState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'jira', ...overrides }; +} + +function renderAdapter( + state: WizardState, + dispatch: (action: WizardAction) => void = () => {}, +): string { + return renderToStaticMarkup(createElement(CredentialsComponent, { state, dispatch })); +} + +/** Recursively collect every React element in a rendered tree. */ +function collectElements(node: unknown, out: ReactElement[] = []): ReactElement[] { + if (Array.isArray(node)) { + for (const child of node) collectElements(child, out); + return out; + } + if (isValidElement(node)) { + out.push(node); + const props = node.props as { children?: unknown }; + if (props?.children !== undefined) collectElements(props.children, out); + } + return out; +} + +describe('JiraCredentialsAdapter auth-type selector (MNG-1744)', () => { + it('renders both basic and scoped options', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-auth-type-selector="jira"'); + expect(html).toContain('data-auth-type-option="basic"'); + expect(html).toContain('data-auth-type-option="scoped"'); + expect(html).toContain('API token'); + expect(html).toContain('API token with scopes'); + }); + + it('still renders the base_url + email + api_token credential inputs', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-role="base_url"'); + expect(html).toContain('data-role="email"'); + expect(html).toContain('data-role="api_token"'); + }); + + it('reflects the basic default in the selected option + helper text', () => { + const html = renderAdapter(jiraState()); + expect(html).toContain('data-auth-type-hint="basic"'); + // basic helper text mentions the classic site URL routing. + expect(html).toContain('site URL'); + // basic is selected, scoped is not. + expect(html).toMatch( + /data-auth-type-option="basic"[^>]*data-selected="true"|data-selected="true"[^>]*data-auth-type-option="basic"/, + ); + expect(html).toMatch( + /data-auth-type-option="scoped"[^>]*data-selected="false"|data-selected="false"[^>]*data-auth-type-option="scoped"/, + ); + }); + + it('reflects the scoped selection in the helper text (api.atlassian.com gateway)', () => { + const html = renderAdapter(jiraState({ jiraAuthType: 'scoped' })); + expect(html).toContain('data-auth-type-hint="scoped"'); + expect(html).toContain('api.atlassian.com'); + expect(html).toMatch( + /data-auth-type-option="scoped"[^>]*data-selected="true"|data-selected="true"[^>]*data-auth-type-option="scoped"/, + ); + }); + + it('dispatches SET_JIRA_AUTH_TYPE with the clicked option value', () => { + const dispatch = vi.fn(); + const tree = CredentialsComponent({ state: jiraState(), dispatch }); + const elements = collectElements(tree); + + const scoped = elements.find( + (el) => (el.props as Record)['data-auth-type-option'] === 'scoped', + ); + expect(scoped).toBeDefined(); + (scoped?.props as { onClick: () => void }).onClick(); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + + const basic = elements.find( + (el) => (el.props as Record)['data-auth-type-option'] === 'basic', + ); + (basic?.props as { onClick: () => void }).onClick(); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_JIRA_AUTH_TYPE', value: 'basic' }); + }); +}); diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index 0d4f8013..755b21f6 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -83,6 +83,29 @@ describe('provider credential metadata', () => { email: 'user@example.com', api_token: 'jira-tok', base_url: 'https://example.atlassian.net', + // jiraAuthType defaults to 'basic' and is threaded as a non-secret + // connection setting (MNG-1744). + auth_type: 'basic', + }, + }); + // Scoped auth_type flows through the same metadata-driven verify bag. + expect( + buildProviderAuthArgFromMetadata( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-tok', + jiraBaseUrl: 'https://example.atlassian.net', + jiraAuthType: 'scoped', + }), + 'proj-j', + jiraProviderWizard.auth, + ), + ).toEqual({ + credentials: { + email: 'user@example.com', + api_token: 'jira-tok', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', }, }); expect( @@ -137,7 +160,12 @@ describe('provider credential metadata', () => { 'email', 'api_token', 'base_url', + 'auth_type', ]); + // auth_type maps to the jiraAuthType wizard-state field (MNG-1744). + expect( + jiraProviderWizard.auth.rawCredentials.find((c) => c.role === 'auth_type')?.stateField, + ).toBe('jiraAuthType'); expect(jiraProviderWizard.credentialPersistence.map((c) => c.envVarKey)).not.toContain( 'JIRA_BASE_URL', ); @@ -266,6 +294,8 @@ describe('metadata-driven verification request', () => { email: 'user@example.com', api_token: 'jira-token', base_url: 'https://example.atlassian.net', + // auth_type is auto-included in the verify bag (defaults to 'basic'). + auth_type: 'basic', }, }); expect( @@ -282,6 +312,31 @@ describe('metadata-driven verification request', () => { }); }); + it('threads scoped auth_type into the JIRA verify (currentUser) credential bag (MNG-1744)', () => { + expect( + buildCurrentUserDiscoveryRequest( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-token', + jiraBaseUrl: 'https://example.atlassian.net', + jiraAuthType: 'scoped', + }), + 'proj-j', + jiraProviderWizard, + ), + ).toEqual({ + providerId: 'jira', + capability: 'currentUser', + args: {}, + credentials: { + email: 'user@example.com', + api_token: 'jira-token', + base_url: 'https://example.atlassian.net', + auth_type: 'scoped', + }, + }); + }); + it('preserves provider-specific verified-as display formatting', () => { expect( trelloProviderWizard.formatVerificationDisplay({ @@ -442,6 +497,8 @@ describe('metadata-driven mutation requests', () => { email: 'user@example.com', api_token: 'jira-token', base_url: 'https://example.atlassian.net', + // auth_type rides along the shared metadata-driven auth-arg builder. + auth_type: 'basic', }, }); }); @@ -650,11 +707,18 @@ describe('jiraProviderWizard.buildIntegrationConfig', () => { expect(config).toEqual({ projectKey: 'PROJ', baseUrl: 'https://example.atlassian.net', + // authType defaults to 'basic' and is always persisted (MNG-1744). + authType: 'basic', statuses: { todo: 'To Do', done: 'Done' }, labels: { processing: 'cascade-processing' }, }); }); + it('persists authType: scoped when jiraAuthType is scoped (MNG-1744)', () => { + const config = jiraProviderWizard.buildIntegrationConfig(seed({ jiraAuthType: 'scoped' })); + expect(config.authType).toBe('scoped'); + }); + it('includes issueTypes when jiraIssueTypes non-empty', () => { const config = jiraProviderWizard.buildIntegrationConfig( seed({ jiraIssueTypes: { task: 'Task', subtask: 'Sub-task' } }), diff --git a/tests/unit/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts index 211baaa6..712aec3c 100644 --- a/tests/unit/web/pm-wizard-state.test.ts +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -44,6 +44,7 @@ describe('createInitialState', () => { expect(state.jiraEmail).toBe(''); expect(state.jiraApiToken).toBe(''); expect(state.jiraBaseUrl).toBe(''); + expect(state.jiraAuthType).toBe('basic'); expect(state.verificationResult).toBeNull(); expect(state.verifyError).toBeNull(); expect(state.trelloBoardId).toBe(''); @@ -96,6 +97,7 @@ describe('provider state slices', () => { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + jiraAuthType: 'basic', jiraProjectKey: '', jiraProjects: [], jiraProjectDetails: null, @@ -175,6 +177,18 @@ describe('provider state slices', () => { expect(next.jiraIssueTypes).toEqual({ task: 'Task', subtask: 'Sub-task' }); }); + it('handles SET_JIRA_AUTH_TYPE in the JIRA state reducer, clearing verification', () => { + const state = { + ...createInitialState(), + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + verifyError: 'stale', + }; + const next = jiraWizardReducer(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + expect(next.jiraAuthType).toBe('scoped'); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + it('handles Linear team reset in the Linear state reducer', () => { const state = { ...createInitialState(), @@ -318,6 +332,25 @@ describe('wizardReducer', () => { expect(next.verifyError).toBeNull(); }); + it('SET_JIRA_AUTH_TYPE sets the auth mode and clears verification (MNG-1744)', () => { + const state = { + ...initialState(), + provider: 'jira' as const, + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + verifyError: 'old error', + }; + const next = dispatch(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'scoped' }); + expect(next.jiraAuthType).toBe('scoped'); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + + it('SET_JIRA_AUTH_TYPE can switch back to basic', () => { + const state = { ...initialState(), jiraAuthType: 'scoped' as const }; + const next = dispatch(state, { type: 'SET_JIRA_AUTH_TYPE', value: 'basic' }); + expect(next.jiraAuthType).toBe('basic'); + }); + it('SET_VERIFICATION stores result and clears error', () => { const state = { ...initialState(), verifyError: 'old error' }; const next = dispatch(state, { @@ -743,6 +776,7 @@ describe('provider-owned edit hydration', () => { const config = { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ', + authType: 'scoped', statuses: { todo: 'To Do', done: 'Done' }, issueTypes: { task: 'Task', subtask: 'Subtask' }, labels: { processing: 'cascade-processing' }, @@ -754,6 +788,8 @@ describe('provider-owned edit hydration', () => { expect(result.jiraEmail).toBeUndefined(); expect(result.jiraApiToken).toBeUndefined(); expect(result.jiraBaseUrl).toBe('https://example.atlassian.net'); + // authType is hydrated from persisted config (MNG-1744). + expect(result.jiraAuthType).toBe('scoped'); expect(result.jiraProjectKey).toBe('PROJ'); expect(result.jiraStatusMappings).toEqual({ todo: 'To Do', done: 'Done' }); expect(result.jiraIssueTypes).toEqual({ task: 'Task', subtask: 'Subtask' }); @@ -761,6 +797,14 @@ describe('provider-owned edit hydration', () => { expect(result.jiraCostFieldId).toBe('customfield_10042'); }); + it('hydrates jiraAuthType to basic for legacy config without authType (MNG-1744)', () => { + const result = jiraProviderWizard.buildEditState( + { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ' }, + new Set(), + ); + expect(result.jiraAuthType).toBe('basic'); + }); + it('sets hasStoredCredentials true for jira when both keys present', () => { const result = jiraProviderWizard.buildEditState( { baseUrl: 'https://example.atlassian.net', projectKey: 'PROJ' }, diff --git a/web/src/components/projects/pm-providers/jira/auth.ts b/web/src/components/projects/pm-providers/jira/auth.ts index 7767419e..6b82b1fc 100644 --- a/web/src/components/projects/pm-providers/jira/auth.ts +++ b/web/src/components/projects/pm-providers/jira/auth.ts @@ -5,6 +5,10 @@ export const jiraAuthMetadata: ProviderAuthMetadata = { { role: 'email', stateField: 'jiraEmail' }, { role: 'api_token', stateField: 'jiraApiToken' }, { role: 'base_url', stateField: 'jiraBaseUrl' }, + // Non-secret connection setting (mirrors base_url). Always has a value + // (defaults to 'basic'), so verify-button readiness is unaffected and the + // verify credential bag auto-includes auth_type for host routing. + { role: 'auth_type', stateField: 'jiraAuthType' }, ], storedCredentials: { fallbackWhenStateFieldEmpty: 'jiraEmail' }, missingCredentialsMessage: 'Enter both credentials before verifying', diff --git a/web/src/components/projects/pm-providers/jira/hooks.ts b/web/src/components/projects/pm-providers/jira/hooks.ts index f2633f9e..a5224a73 100644 --- a/web/src/components/projects/pm-providers/jira/hooks.ts +++ b/web/src/components/projects/pm-providers/jira/hooks.ts @@ -41,6 +41,9 @@ export function useJiraDiscovery( email: state.jiraEmail, api_token: state.jiraApiToken, base_url: state.jiraBaseUrl, + // Non-secret connection setting — routes discovery through the + // correct host (site URL for basic, api.atlassian.com for scoped). + auth_type: state.jiraAuthType, }, })) as Array<{ id: string; name: string }>; return projects.map((p) => ({ key: p.id, name: p.name })); @@ -63,6 +66,9 @@ export function useJiraDiscovery( email: state.jiraEmail, apiToken: state.jiraApiToken, baseUrl: state.jiraBaseUrl, + // Non-secret connection setting — routes project-details discovery + // through the correct host (site URL for basic, gateway for scoped). + authType: state.jiraAuthType, projectKey, }); }, diff --git a/web/src/components/projects/pm-providers/jira/state.ts b/web/src/components/projects/pm-providers/jira/state.ts index 08417f28..0843b824 100644 --- a/web/src/components/projects/pm-providers/jira/state.ts +++ b/web/src/components/projects/pm-providers/jira/state.ts @@ -1,3 +1,18 @@ +/** + * JIRA authentication mode. NON-secret connection setting (mirrors + * `baseUrl`), NOT a credential — both modes still authenticate via HTTP + * Basic with `email:api_token`. The enum distinguishes the token class / + * host routing: + * - `'basic'` — classic API token; Jira REST API at the site URL. + * - `'scoped'` — scoped API token; CASCADE routes Jira REST API calls + * through the `api.atlassian.com/ex/jira/{cloudId}` gateway using the + * token's granular scopes. + * + * Single source of truth for the wizard-state literal union so + * `pm-wizard-state.ts` and the provider slice cannot drift. + */ +export type JiraWizardAuthType = 'basic' | 'scoped'; + export interface JiraProjectOption { key: string; name: string; @@ -21,6 +36,7 @@ export interface JiraWizardStateSlice { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + jiraAuthType: JiraWizardAuthType; jiraProjectKey: string; jiraProjects: JiraProjectOption[]; jiraProjectDetails: JiraProjectDetails | null; @@ -39,6 +55,7 @@ export type JiraWizardAction = | { type: 'SET_JIRA_EMAIL'; value: string } | { type: 'SET_JIRA_API_TOKEN'; value: string } | { type: 'SET_JIRA_BASE_URL'; url: string } + | { type: 'SET_JIRA_AUTH_TYPE'; value: JiraWizardAuthType } | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } | { type: 'SET_JIRA_PROJECT_KEY'; key: string } | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } @@ -53,6 +70,7 @@ export function createInitialJiraState(): JiraWizardStateSlice { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + jiraAuthType: 'basic', jiraProjectKey: '', jiraProjects: [], jiraProjectDetails: null, @@ -93,6 +111,15 @@ export function jiraWizardReducer | undefined): JiraPr // ── Per-step adapters ──────────────────────────────────────────────── +// Basic vs scoped token selector options. Presented as a host-routing / +// security-scope choice, NOT a different auth protocol — the operator still +// enters email + API token in both modes (see MNG-1735 research). +const JIRA_AUTH_TYPE_OPTIONS: ReadonlyArray<{ + readonly value: JiraWizardAuthType; + readonly label: string; + readonly hint: string; +}> = [ + { + value: 'basic', + label: 'API token', + hint: 'Classic API token. CASCADE calls the Jira REST API at your site URL.', + }, + { + value: 'scoped', + label: 'API token with scopes', + hint: 'Scoped API token — CASCADE routes Jira REST API calls through the api.atlassian.com gateway using the token’s granular scopes. You still enter your email + API token.', + }, +]; + +/** + * Segmented control for the JIRA auth-type (basic vs scoped). Rendered via + * shadcn `Button` primitives (SSR-safe; no raw radio/select) and dispatches + * `SET_JIRA_AUTH_TYPE`. Shows the selected option's helper text below. + */ +function JiraAuthTypeSelector({ + value, + onChange, +}: { + value: JiraWizardAuthType; + onChange: (next: JiraWizardAuthType) => void; +}): ReactElement { + const activeHint = JIRA_AUTH_TYPE_OPTIONS.find((o) => o.value === value)?.hint ?? ''; + return createElement( + 'div', + { className: 'space-y-2', 'data-auth-type-selector': 'jira' }, + createElement(Label, null, 'Token type'), + createElement( + 'div', + { className: 'flex gap-2', role: 'radiogroup', 'aria-label': 'JIRA token type' }, + ...JIRA_AUTH_TYPE_OPTIONS.map((opt) => + createElement( + Button, + { + key: opt.value, + type: 'button', + variant: value === opt.value ? 'default' : 'outline', + size: 'sm', + role: 'radio', + 'aria-checked': value === opt.value, + 'data-auth-type-option': opt.value, + 'data-selected': value === opt.value ? 'true' : 'false', + onClick: () => onChange(opt.value), + } as React.ComponentProps & DataProps, + opt.label, + ), + ), + ), + createElement( + 'p', + { className: 'text-xs text-muted-foreground', 'data-auth-type-hint': value }, + activeHint, + ), + ); +} + function JiraCredentialsAdapter({ state, dispatch }: ProviderWizardStepProps): ReactElement { - return CredentialsStep({ - step: { kind: 'credentials', id: 'jira-credentials' }, - providerId: 'jira', - credentialRoles: JIRA_CREDENTIAL_ROLES, - values: { - base_url: state.jiraBaseUrl, - email: state.jiraEmail, - api_token: state.jiraApiToken, - }, - onChange: (role, value) => { - if (role === 'base_url') dispatch({ type: 'SET_JIRA_BASE_URL', url: value }); - else if (role === 'email') dispatch({ type: 'SET_JIRA_EMAIL', value }); - else if (role === 'api_token') dispatch({ type: 'SET_JIRA_API_TOKEN', value }); - }, - }); + return createElement( + 'div', + { className: 'space-y-4' }, + JiraAuthTypeSelector({ + value: state.jiraAuthType, + onChange: (value) => dispatch({ type: 'SET_JIRA_AUTH_TYPE', value }), + }), + CredentialsStep({ + step: { kind: 'credentials', id: 'jira-credentials' }, + providerId: 'jira', + credentialRoles: JIRA_CREDENTIAL_ROLES, + values: { + base_url: state.jiraBaseUrl, + email: state.jiraEmail, + api_token: state.jiraApiToken, + }, + onChange: (role, value) => { + if (role === 'base_url') dispatch({ type: 'SET_JIRA_BASE_URL', url: value }); + else if (role === 'email') dispatch({ type: 'SET_JIRA_EMAIL', value }); + else if (role === 'api_token') dispatch({ type: 'SET_JIRA_API_TOKEN', value }); + }, + }), + ); } function JiraProjectPickAdapter({ state, providerHooks }: ProviderWizardStepProps): ReactElement { @@ -302,6 +380,9 @@ export const jiraProviderWizard: ProviderWizardDefinition = { buildIntegrationConfig: (state) => ({ projectKey: state.jiraProjectKey, baseUrl: state.jiraBaseUrl, + // Non-secret connection setting persisted alongside baseUrl (mirrors the + // backend jiraConfigSchema.authType). Later stories read it for host routing. + authType: state.jiraAuthType, statuses: state.jiraStatusMappings, ...(Object.keys(state.jiraIssueTypes).length > 0 ? { issueTypes: state.jiraIssueTypes } : {}), ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), @@ -323,6 +404,9 @@ export const jiraProviderWizard: ProviderWizardDefinition = { return { provider: 'jira', jiraBaseUrl: (initialConfig.baseUrl as string) ?? '', + // Hydrate the persisted auth mode; legacy configs without authType + // (saved before this field existed) default to 'basic'. + jiraAuthType: (initialConfig.authType as JiraWizardAuthType | undefined) ?? 'basic', jiraProjectKey: (initialConfig.projectKey as string) ?? '', ...(statuses ? { jiraStatusMappings: statuses } : {}), ...(issueTypes ? { jiraIssueTypes: issueTypes } : {}), diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 78a8c99a..04a53e6d 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -10,6 +10,7 @@ import { type JiraProjectDetails, type JiraProjectOption, type JiraWizardAction, + type JiraWizardAuthType, jiraWizardReducer, } from './pm-providers/jira/state.js'; import { @@ -70,6 +71,7 @@ export interface WizardState { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + jiraAuthType: JiraWizardAuthType; linearApiKey: string; verificationResult: { provider: Provider; display: string } | null; verifyError: string | null; From abaab435f6f3fa3c5acbaac661f9fcfccbcd4a5c Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 10 Jul 2026 22:25:51 +0200 Subject: [PATCH 11/11] docs(jira): document scoped API token support (authType, gateway routing, limitations) (#1492) Co-authored-by: Cascade Bot --- CLAUDE.md | 4 ++++ docs/architecture/06-integration-layer.md | 2 ++ docs/architecture/08-config-credentials.md | 25 ++++++++++++++++++-- docs/getting-started.md | 23 ++++++++++++++++++ src/integrations/README.md | 27 ++++++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a034bb9d..9de7ed7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index 45a80542..3988693f 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -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/`) diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 6c91204b..33880311 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -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 @@ -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` diff --git a/docs/getting-started.md b/docs/getting-started.md index 4de027ac..84b301a7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/src/integrations/README.md b/src/integrations/README.md index 6e825b2f..441c7641 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -112,6 +112,33 @@ Single-source-of-truth utilities live in `src/integrations/pm/_shared/`: - **`label-id-resolver.ts`** — `resolveLabelId(slot, mapping, ctx)` validates UUIDs before passing labelIds to APIs that require them (Linear). Returns `null` and logs a warn for misconfigurations. - **`project-id-extractor.ts`** — `extractProjectIdFromJobViaRegistry(jobData)` iterates the registry. Used by `src/router/worker-env.ts` before its (now-minimal) legacy branches. +JIRA has one additional shared host resolver outside `_shared/` — `resolveJiraApiBaseUrl` (`src/jira/api-host.ts`), the JIRA analogue of the shared auth-header helper. See the next section. + +--- + +## JIRA authentication modes (scoped tokens) + +JIRA supports classic unscoped **site tokens** and Atlassian **API tokens with scopes** ("scoped" tokens). The mode is chosen by the optional `authType` field on the JIRA integration config (`jiraConfigSchema` in `src/integrations/pm/jira/config-schema.ts`): `'basic'` (or absent) and `'scoped'`. + +- **`authType` is a non-secret connection setting**, not a credential role — it mirrors `baseUrl` and lives in `project_integrations.config`, never `project_credentials`. Absent ⇒ `'basic'`, so every pre-existing JIRA config stays valid untouched (`normalizeJiraAuthType` maps absent/unknown values back to `'basic'`). +- **Both modes authenticate with HTTP Basic** (`email:api_token`) — confirmed live in MNG-1735. `authType` selects the REST v3 *host*, not a Basic-vs-Bearer scheme. There is no scoped-Bearer branch. + +### `resolveJiraApiBaseUrl` — the shared host-resolution contract + +Every JIRA REST v3 call site must route through the single shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`) so scoped tokens hit the Atlassian gateway consistently and no divergent host-selection copies exist. This is the JIRA analogue of the `auth-headers.ts` provenance rule: + +| `authType` | Resolved REST v3 base | Detail | +|---|---|---| +| `basic` / absent | tenant **site URL** (`creds.baseUrl`) | Classic site-token behavior, unchanged. | +| `scoped` | Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}` | `cloudId` resolved from `${baseUrl}/_edge/tenant_info` (**always** the site URL, never the gateway) with the same Basic scoped token, cached per `baseUrl` via `jiraClient.getCloudId`. | + +The in-worker `jiraClient` (`src/jira/client.ts`), the router-side `JiraPlatformClient` (`src/router/platformClients/jira.ts`), the webhook-management router (`src/api/routers/webhooks/jira.ts`), and the wizard's discovery factory (`jiraManifest.createDiscoveryProvider` / `configToCredentials`) all resolve the effective base this way. The worker/CLI credential scope carries the mode across process boundaries via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by `secretBuilder.augmentProjectSecrets`). `accessible-resources` is intentionally **not** used as a cloudId fallback — it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens (MNG-1735). + +### Required scopes and known limitations + +- **Scopes.** Read/write Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management (`/rest/api/3/webhook`) additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. A scoped token without those scopes (or a non-app caller) gets `401`/`403`; `jiraCreateWebhook` surfaces an actionable message pointing at manual registration (Jira **System → WebHooks**) rather than a raw status dump. +- **Ack reactions are unavailable under scoped tokens.** The "eyes" reaction uses the internal `/rest/reactions/1.0/` API, which is not confirmed on the scoped gateway (the gateway probe returned `401 scope does not match`; MNG-1735). Under `scoped` auth both `jiraClient.addCommentReaction` and `JiraPlatformClient.postReaction` degrade quietly (one log line, then skip). The reaction is cosmetic and must never fail a run; comments, transitions, and label writes are unaffected. + --- ## Registration at startup