diff --git a/apps/sim/app/api/tools/atlassian-server-resolved-selectors.test.ts b/apps/sim/app/api/tools/atlassian-server-resolved-selectors.test.ts new file mode 100644 index 00000000000..40f46ba1ac1 --- /dev/null +++ b/apps/sim/app/api/tools/atlassian-server-resolved-selectors.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + resolveContext: vi.fn(), + resolveAtlassianCredential: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({ + authenticateSelectorRequest: mocks.authenticate, + resolveAuthorizedSelectorContext: mocks.resolveContext, +})) +vi.mock('@/lib/selectors/application/atlassian-credential', () => ({ + resolveAtlassianSelectorCredential: mocks.resolveAtlassianCredential, +})) + +import { POST as confluencePages } from '@/app/api/tools/confluence/selector-pages/route' +import { POST as jiraProject } from '@/app/api/tools/jira/projects/route' + +const principal = { + kind: 'session', + userId: 'viewer-1', + sessionId: 'session-1', +} as const + +function request(path: string, body: unknown) { + return createMockRequest( + 'POST', + body, + { 'content-type': 'application/json' }, + `http://localhost:3000${path}` + ) +} + +describe('server-resolved Atlassian selector routes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + mocks.authenticate.mockResolvedValue({ ok: true, principal }) + mocks.resolveContext.mockImplementation( + async (_principal: unknown, input: { context: Record }) => ({ + ok: true, + context: { ...input.context, domain: 'resolved-secret.example.com' }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + credentialAccess: { credentialOwnerUserId: 'owner-1' }, + }) + ) + mocks.resolveAtlassianCredential.mockResolvedValue({ + accessToken: 'atlassian-token', + cloudId: 'cloud-id-1', + }) + }) + + it('authenticates before parsing a malformed request', async () => { + mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' }) + + const response = await jiraProject( + request('/api/tools/jira/projects', { definitely: 'not a Jira selector request' }) + ) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'Jira Project', + route: jiraProject, + path: '/api/tools/jira/projects', + body: { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{INACCESSIBLE_SECRET}}', + projectId: 'SIM', + }, + }, + { + name: 'Confluence Page', + route: confluencePages, + path: '/api/tools/confluence/selector-pages', + body: { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{INACCESSIBLE_SECRET}}', + }, + }, + ])('$name stops inaccessible references before provider access', async (testCase) => { + mocks.resolveContext.mockResolvedValue({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + const providerFetch = vi.fn() + vi.stubGlobal('fetch', providerFetch) + + const response = await testCase.route(request(testCase.path, testCase.body)) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Unable to resolve selector configuration' }) + expect(mocks.resolveAtlassianCredential).not.toHaveBeenCalled() + expect(providerFetch).not.toHaveBeenCalled() + }) + + it('maps Jira projects without exposing resolved provider data', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + Response.json({ id: '10001', name: 'Sim', self: 'https://resolved-secret.example.com' }) + ) + ) + + const response = await jiraProject( + request('/api/tools/jira/projects', { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{DOMAIN}}', + projectId: 'SIM', + }) + ) + + expect(await response.json()).toEqual({ project: { id: '10001', name: 'Sim' } }) + }) + + it('maps Confluence pages without exposing resolved provider data', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + results: [ + { + id: '20001', + title: 'Runbook', + _links: { webui: 'https://resolved-secret.example.com/wiki/runbook' }, + }, + ], + }) + ) + ) + + const response = await confluencePages( + request('/api/tools/confluence/selector-pages', { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{DOMAIN}}', + }) + ) + + expect(await response.json()).toEqual({ files: [{ id: '20001', name: 'Runbook' }] }) + }) + + it('maps provider failures to a stable public response without reading their body', async () => { + const providerText = vi.fn().mockResolvedValue('provider-body-secret-marker') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 418, text: providerText }) + ) + + const response = await jiraProject( + request('/api/tools/jira/projects', { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{DOMAIN}}', + projectId: 'SIM', + }) + ) + + expect(response.status).toBe(502) + expect(await response.json()).toEqual({ + error: 'Jira selector discovery failed.', + status: 502, + }) + expect(providerText).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/confluence/selector-page/route.ts b/apps/sim/app/api/tools/confluence/selector-page/route.ts new file mode 100644 index 00000000000..df4582bb2ce --- /dev/null +++ b/apps/sim/app/api/tools/confluence/selector-page/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSelectorPageContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' +import { + resolveSelectorProviderValue, + SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, + selectorProviderFailure, +} from '@/lib/selectors/server/provider-errors' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { getConfluenceCloudId } from '@/tools/confluence/utils' + +const logger = createLogger('ConfluenceSelectorPageAPI') + +export const dynamic = 'force-dynamic' + +interface ConfluencePageResponse { + id: string + title: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) + } + const parsed = await parseRequest(confluenceSelectorPageContract, request, {}) + if (!parsed.success) return parsed.response + + const { credential, workflowId, domain: domainReference, pageId } = parsed.data.body + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + + const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!credentialOwnerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId, + requestId: generateRequestId(), + serviceId: 'confluence', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + + const domain = resolution.context.domain as string + const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () => + bundle.cloudId + ? bundle.cloudId + : getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Confluence selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages/${pageId}` + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` }, + }) + if (!response.ok) { + logger.warn('Confluence selector page request failed', { status: response.status }) + const failure = selectorProviderFailure('Confluence', response.status) + return NextResponse.json(failure, { status: failure.status }) + } + + const page = (await response.json()) as ConfluencePageResponse + return NextResponse.json({ id: page.id, title: page.title }) + } catch { + logger.error('Error retrieving Confluence selector page') + return NextResponse.json({ error: 'Failed to retrieve Confluence page' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/confluence/selector-pages/route.ts b/apps/sim/app/api/tools/confluence/selector-pages/route.ts new file mode 100644 index 00000000000..9bfd44fffab --- /dev/null +++ b/apps/sim/app/api/tools/confluence/selector-pages/route.ts @@ -0,0 +1,110 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSelectorPagesContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' +import { + resolveSelectorProviderValue, + SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, + selectorProviderFailure, +} from '@/lib/selectors/server/provider-errors' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { getConfluenceCloudId } from '@/tools/confluence/utils' + +const logger = createLogger('ConfluenceSelectorPagesAPI') + +export const dynamic = 'force-dynamic' + +interface ConfluencePageRow { + id: string + title: string +} + +interface ConfluencePagesResponse { + results?: ConfluencePageRow[] +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) + } + const parsed = await parseRequest(confluenceSelectorPagesContract, request, {}) + if (!parsed.success) return parsed.response + + const { credential, workflowId, domain: domainReference, title, limit } = parsed.data.body + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + + const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!credentialOwnerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId, + requestId: generateRequestId(), + serviceId: 'confluence', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + + const domain = resolution.context.domain as string + const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () => + bundle.cloudId + ? bundle.cloudId + : getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Confluence selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const search = new URLSearchParams({ limit: String(limit) }) + if (title) search.set('title', title) + const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages?${search.toString()}` + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` }, + }) + if (!response.ok) { + logger.warn('Confluence selector pages request failed', { status: response.status }) + const failure = selectorProviderFailure('Confluence', response.status) + return NextResponse.json(failure, { status: failure.status }) + } + + const data = (await response.json()) as ConfluencePagesResponse + return NextResponse.json({ + files: (data.results ?? []).map((page) => ({ + id: page.id, + name: page.title, + })), + }) + } catch { + logger.error('Error listing Confluence selector pages') + return NextResponse.json({ error: 'Failed to retrieve Confluence pages' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts index ca7431294c5..6a6f48d5fae 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -2,18 +2,20 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { confluenceSpacesSelectorContract } from '@/lib/api/contracts/selectors/confluence' import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' import { - getAtlassianServiceAccountSecret, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/lib/oauth/credential-service' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' + resolveSelectorProviderValue, + SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, + selectorProviderFailure, +} from '@/lib/selectors/server/provider-errors' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' import { getConfluenceCloudId } from '@/tools/confluence/utils' -import { parseAtlassianErrorMessage } from '@/tools/jira/utils' const logger = createLogger('ConfluenceSelectorSpacesAPI') @@ -63,56 +65,54 @@ function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: st export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) + } const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { credential, workflowId, domain, cursor, spaceKey } = parsed.data.body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + const { credential, workflowId, domain: domainReference, cursor, spaceKey } = parsed.data.body + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) } - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!credentialOwnerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) } - - const authz = await authorizeCredentialUse(request, { + const bundle = await resolveAtlassianSelectorCredential({ credentialId: credential, - workflowId, + credentialOwnerUserId, + requestId, + serviceId: 'confluence', }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const resolved = await resolveOAuthAccountId(credential) - const isAtlassianServiceAccount = - resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId - - let accessToken: string | null - let cloudId: string - if (isAtlassianServiceAccount) { - const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!) - accessToken = secret.apiToken - cloudId = secret.cloudId - } else { - accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId + if (!bundle) { + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - cloudId = await getConfluenceCloudId(domain, accessToken) } + const domain = resolution.context.domain as string + const accessToken = bundle.accessToken + const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () => + bundle.cloudId + ? bundle.cloudId + : getConfluenceCloudId(domain, accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Confluence selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -131,10 +131,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - const errorText = await response.text() - const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText) - logger.error('Confluence API error response', { error: message, status: response.status }) - return { ok: false, response: NextResponse.json({ error: message }, { status: 502 }) } + logger.warn('Confluence selector spaces request failed', { status: response.status }) + const failure = selectorProviderFailure('Confluence', response.status) + return { + ok: false, + response: NextResponse.json(failure, { status: failure.status }), + } } return { ok: true, data: await response.json() } @@ -205,11 +207,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return NextResponse.json({ spaces: toSpaces(data.results, status), nextCursor }) - } catch (error) { - logger.error('Error listing Confluence spaces:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) + } catch { + logger.error('Error listing Confluence spaces') + return NextResponse.json({ error: 'Failed to retrieve Confluence spaces' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index 34e92befb13..eaa2f26e6f9 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -5,36 +5,35 @@ import { jiraIssuesSelectorContract, } from '@/lib/api/contracts/selectors/jira' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' +import { + resolveSelectorProviderValue, + SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, + selectorProviderFailure, +} from '@/lib/selectors/server/provider-errors' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JiraIssuesAPI') -const createErrorResponse = async (response: Response) => { - const errorText = await response.text().catch(() => '') - return parseAtlassianErrorMessage(response.status, response.statusText, errorText) -} - export const POST = withRouteHandler(async (request: NextRequest) => { try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) } - const parsed = await parseRequest(jiraIssueSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { domain, accessToken, issueKeys, cloudId: providedCloudId } = parsed.data.body - - if (issueKeys.length === 0) { - logger.info('No issue keys provided, returning empty result') - return NextResponse.json({ issues: [] }) - } + const { credential, workflowId, domain: domainReference, issueKeys } = parsed.data.body const ISSUE_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ const sanitizedKeys: string[] = [] @@ -46,11 +45,47 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } sanitizedKeys.push(trimmed) } + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } if (sanitizedKeys.length === 0) { + logger.info('No issue keys provided, returning empty result') return NextResponse.json({ issues: [] }) } - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) + const ownerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!ownerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId: ownerUserId, + requestId: generateRequestId(), + serviceId: 'jira', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + const domain = resolution.context.domain as string + const accessToken = bundle.accessToken + const cloudIdResolution = await resolveSelectorProviderValue('Jira', async () => + bundle.cloudId + ? bundle.cloudId + : getJiraCloudId(domain, accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Jira selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -75,55 +110,37 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - logger.error(`Jira API error: ${response.status} ${response.statusText}`) - const errorMessage = await createErrorResponse(response) - if (response.status === 401 || response.status === 403) { - return NextResponse.json( - { - error: errorMessage, - authRequired: true, - requiredScopes: ['read:jira-work'], - }, - { status: response.status } - ) - } - return NextResponse.json({ error: errorMessage }, { status: response.status }) + logger.warn('Jira selector issue detail request failed', { status: response.status }) + const failure = selectorProviderFailure('Jira', response.status) + return NextResponse.json(failure, { status: failure.status }) } const data = await response.json() const issues = (data.issues || []).map((it: any) => ({ id: it.key, name: it.fields?.summary || it.key, - mimeType: 'jira/issue', - url: `https://${domain}/browse/${it.key}`, - modifiedTime: it.fields?.updated, - webViewLink: `https://${domain}/browse/${it.key}`, })) - return NextResponse.json({ issues, cloudId }) - } catch (error) { - logger.error('Error fetching Jira issues:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ issues }) + } catch { + logger.error('Error fetching Jira issues') + return NextResponse.json({ error: 'Failed to retrieve Jira issues' }, { status: 500 }) } }) export const GET = withRouteHandler(async (request: NextRequest) => { try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) } - const parsed = await parseRequest(jiraIssuesSelectorContract, request, {}) if (!parsed.success) return parsed.response const { - domain, - accessToken, - cloudId: providedCloudId, + credential, + workflowId, + domain: domainReference, query = '', projectId = '', manualProjectId = '', @@ -131,7 +148,43 @@ export const GET = withRouteHandler(async (request: NextRequest) => { limit, } = parsed.data.query - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + const ownerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!ownerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId: ownerUserId, + requestId: generateRequestId(), + serviceId: 'jira', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + const domain = resolution.context.domain as string + const accessToken = bundle.accessToken + const cloudIdResolution = await resolveSelectorProviderValue('Jira', async () => + bundle.cloudId + ? bundle.cloudId + : getJiraCloudId(domain, accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Jira selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -196,18 +249,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - const errorMessage = await createErrorResponse(response) - if (response.status === 401 || response.status === 403) { - return NextResponse.json( - { - error: errorMessage, - authRequired: true, - requiredScopes: ['read:jira-work'], - }, - { status: response.status } - ) - } - return NextResponse.json({ error: errorMessage }, { status: response.status }) + logger.warn('Jira selector issue request failed', { status: response.status }) + const failure = selectorProviderFailure('Jira', response.status) + return NextResponse.json(failure, { status: failure.status }) } const page = await response.json() @@ -221,17 +265,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { key: it.key, summary: it.fields?.summary || it.key, })) - data = { sections: [{ issues }], cloudId } + data = { sections: [{ issues }] } } else { - data = { sections: [], cloudId } + data = { sections: [] } } - return NextResponse.json({ ...data, cloudId }) - } catch (error) { - logger.error('Error fetching Jira issue suggestions:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json(data) + } catch { + logger.error('Error fetching Jira issue suggestions') + return NextResponse.json({ error: 'Failed to retrieve Jira issues' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts index 2ee1244cb2f..f90924a2c08 100644 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ b/apps/sim/app/api/tools/jira/projects/route.ts @@ -5,10 +5,20 @@ import { jiraProjectsSelectorContract, } from '@/lib/api/contracts/selectors/jira' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' +import { + resolveSelectorProviderValue, + SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, + selectorProviderFailure, +} from '@/lib/selectors/server/provider-errors' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -87,25 +97,51 @@ async function fetchAllJiraProjects( export const GET = withRouteHandler(async (request: NextRequest) => { try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) } - const parsed = await parseRequest(jiraProjectsSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { domain, accessToken, cloudId: providedCloudId, query = '' } = parsed.data.query - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + const { credential, workflowId, domain: domainReference, query = '' } = parsed.data.query + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + const ownerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!ownerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) } - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId: ownerUserId, + requestId: generateRequestId(), + serviceId: 'jira', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + const domain = resolution.context.domain as string + const accessToken = bundle.accessToken + const cloudIdResolution = await resolveSelectorProviderValue('Jira', async () => + bundle.cloudId + ? bundle.cloudId + : getJiraCloudId(domain, accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Jira selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value logger.info(`Using cloud ID: ${cloudId}`) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') @@ -125,18 +161,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { values, lastResponse } = await fetchAllJiraProjects(apiUrl, queryParams, accessToken) if (!lastResponse.ok) { - const errorText = await lastResponse.text() - logger.error('Jira API error:', { status: lastResponse.status, error: errorText }) - return NextResponse.json( - { - error: parseAtlassianErrorMessage( - lastResponse.status, - lastResponse.statusText, - errorText - ), - }, - { status: lastResponse.status } - ) + logger.warn('Jira selector project request failed', { status: lastResponse.status }) + const failure = selectorProviderFailure('Jira', lastResponse.status) + return NextResponse.json(failure, { status: failure.status }) } logger.info(`Jira API Response Status: ${lastResponse.status}`) @@ -145,55 +172,68 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const projects = values.map((project: any) => ({ id: project.id, - key: project.key, name: project.name, - url: project.self, - avatarUrl: project.avatarUrls?.['48x48'], - description: project.description, - projectTypeKey: project.projectTypeKey, - simplified: project.simplified, - style: project.style, - isPrivate: project.isPrivate, })) || [] - return NextResponse.json({ - projects, - cloudId, - }) - } catch (error) { - logger.error('Error fetching Jira projects:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ projects }) + } catch { + logger.error('Error fetching Jira projects') + return NextResponse.json({ error: 'Failed to retrieve Jira projects' }, { status: 500 }) } }) export const POST = withRouteHandler(async (request: NextRequest) => { try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) } - const parsed = await parseRequest(jiraProjectSelectorContract, request, {}) if (!parsed.success) return parsed.response - const { domain, accessToken, projectId, cloudId: providedCloudId } = parsed.data.body - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } + const { credential, workflowId, domain: domainReference, projectId } = parsed.data.body if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) } - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + credentialId: credential, + context: { domain: domainReference }, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + const ownerUserId = resolution.credentialAccess?.credentialOwnerUserId + if (!ownerUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + const bundle = await resolveAtlassianSelectorCredential({ + credentialId: credential, + credentialOwnerUserId: ownerUserId, + requestId: generateRequestId(), + serviceId: 'jira', + }) + if (!bundle) { + return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) + } + const domain = resolution.context.domain as string + const accessToken = bundle.accessToken + const cloudIdResolution = await resolveSelectorProviderValue('Jira', async () => + bundle.cloudId + ? bundle.cloudId + : getJiraCloudId(domain, accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) + ) + if (!cloudIdResolution.ok) { + logger.warn('Jira selector discovery failed', { + status: cloudIdResolution.upstreamStatus ?? 'unknown', + }) + return NextResponse.json(cloudIdResolution.failure, { + status: cloudIdResolution.failure.status, + }) + } + const cloudId = cloudIdResolution.value const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -216,12 +256,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - const errorText = await response.text() - logger.error('Jira API error:', { status: response.status, error: errorText }) - return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } - ) + logger.warn('Jira selector project detail request failed', { status: response.status }) + const failure = selectorProviderFailure('Jira', response.status) + return NextResponse.json(failure, { status: failure.status }) } const project = await response.json() @@ -229,23 +266,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ project: { id: project.id, - key: project.key, name: project.name, - url: project.self, - avatarUrl: project.avatarUrls?.['48x48'], - description: project.description, - projectTypeKey: project.projectTypeKey, - simplified: project.simplified, - style: project.style, - isPrivate: project.isPrivate, }, - cloudId, }) - } catch (error) { - logger.error('Error fetching Jira project:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) + } catch { + logger.error('Error fetching Jira project') + return NextResponse.json({ error: 'Failed to retrieve Jira project' }, { status: 500 }) } }) diff --git a/apps/sim/hooks/selectors/providers/atlassian-server-resolved.test.ts b/apps/sim/hooks/selectors/providers/atlassian-server-resolved.test.ts new file mode 100644 index 00000000000..4a7d37e1690 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/atlassian-server-resolved.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ requestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) + +import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' +import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors' + +describe('server-resolved Atlassian selector providers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opts Jira and Confluence into only the domain context field', () => { + expect(jiraSelectors['jira.projects'].serverResolvedContextFields).toEqual(['domain']) + expect(jiraSelectors['jira.issues'].serverResolvedContextFields).toEqual(['domain']) + expect(confluenceSelectors['confluence.spaces'].serverResolvedContextFields).toEqual(['domain']) + expect(confluenceSelectors['confluence.pages'].serverResolvedContextFields).toEqual(['domain']) + }) + + it('keeps literal domains out of Jira and Confluence base query keys', () => { + const context = { + oauthCredential: 'credential-1', + domain: 'private-tenant.atlassian.net', + } + const keys = [ + jiraSelectors['jira.projects'].getQueryKey!({ key: 'jira.projects', context }), + confluenceSelectors['confluence.pages'].getQueryKey!({ key: 'confluence.pages', context }), + ] + + expect(JSON.stringify(keys)).not.toContain(context.domain) + }) + + it('sends a raw Jira domain reference to the authorized selector route', async () => { + mocks.requestJson.mockResolvedValue({ projects: [] }) + + await jiraSelectors['jira.projects'].fetchList!({ + key: 'jira.projects', + context: { + workflowId: 'workflow-1', + oauthCredential: 'credential-1', + domain: '{{SHARED_DOMAIN}}', + }, + }) + + expect(mocks.requestJson).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + query: { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{SHARED_DOMAIN}}', + query: undefined, + }, + }) + ) + }) + + it('uses the dedicated Confluence page selector route without browser OAuth tokens', async () => { + mocks.requestJson.mockResolvedValue({ files: [] }) + + await confluenceSelectors['confluence.pages'].fetchList!({ + key: 'confluence.pages', + context: { + workflowId: 'workflow-1', + oauthCredential: 'credential-1', + domain: '{{PERSONAL_DOMAIN}}', + }, + }) + + const [, input] = mocks.requestJson.mock.calls[0] + expect(input.body).toEqual({ + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{PERSONAL_DOMAIN}}', + title: undefined, + }) + expect(input.body).not.toHaveProperty('accessToken') + }) + + it.each([ + ['Jira', jiraSelectors['jira.projects']], + ['Confluence', confluenceSelectors['confluence.spaces']], + ])('keeps workflowless %s credential connector selectors enabled', (_label, definition) => { + expect( + definition.enabled?.({ + key: definition.key, + context: { + workspaceId: 'workspace-1', + oauthCredential: 'credential-1', + domain: 'tenant.atlassian.net', + }, + }) + ).toBe(true) + }) + + it('sends workflowless credential-backed Jira and Confluence requests', async () => { + mocks.requestJson + .mockResolvedValueOnce({ projects: [{ id: '10001', name: 'Sim' }] }) + .mockResolvedValueOnce({ spaces: [{ key: 'SPACE', name: 'Docs' }] }) + + const context = { + workspaceId: 'workspace-1', + oauthCredential: 'credential-1', + domain: 'tenant.atlassian.net', + } + const jira = await jiraSelectors['jira.projects'].fetchList!({ key: 'jira.projects', context }) + const confluence = await confluenceSelectors['confluence.spaces'].fetchPage!({ + key: 'confluence.spaces', + context, + }) + + expect(jira).toEqual([{ id: '10001', label: 'Sim' }]) + expect(confluence.items).toEqual([{ id: 'SPACE', label: 'Docs (SPACE)' }]) + for (const [, input] of mocks.requestJson.mock.calls) { + const requestValues = (input.body ?? input.query) as Record + expect(requestValues.credential).toBe('credential-1') + expect(requestValues).not.toHaveProperty('workflowId') + } + }) +}) diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index a0c2d352142..0f23c8bccad 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -1,6 +1,5 @@ import { requestJson } from '@/lib/api/client/request' import * as selectorContracts from '@/lib/api/contracts/selectors' -import { fetchOAuthToken } from '@/hooks/selectors/helpers' import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' @@ -20,12 +19,12 @@ export const confluenceSelectors = { 'confluence.spaces': { key: 'confluence.spaces', contracts: [selectorContracts.confluenceSpacesSelectorContract], + serverResolvedContextFields: ['domain'], staleTime: SELECTOR_STALE, getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'confluence.spaces', context.oauthCredential ?? 'none', - context.domain ?? 'none', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), /** @@ -38,7 +37,7 @@ export const confluenceSelectors = { const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, { body: { credential: credentialId, - workflowId: context.workflowId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, cursor, }, @@ -63,7 +62,7 @@ export const confluenceSelectors = { const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, { body: { credential: credentialId, - workflowId: context.workflowId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, spaceKey: detailId, }, @@ -77,15 +76,15 @@ export const confluenceSelectors = { 'confluence.pages': { key: 'confluence.pages', contracts: [ - selectorContracts.confluencePagesSelectorContract, - selectorContracts.confluencePageSelectorContract, + selectorContracts.confluenceSelectorPagesContract, + selectorContracts.confluenceSelectorPageContract, ], + serverResolvedContextFields: ['domain'], staleTime: SELECTOR_STALE, getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'confluence.pages', context.oauthCredential ?? 'none', - context.domain ?? 'none', search ?? '', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), @@ -102,15 +101,11 @@ export const confluenceSelectors = { fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Confluence access token') - } - const data = await requestJson(selectorContracts.confluencePagesSelectorContract, { + const data = await requestJson(selectorContracts.confluenceSelectorPagesContract, { body: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, title: search, }, signal, @@ -124,15 +119,11 @@ export const confluenceSelectors = { if (!detailId) return null const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Confluence access token') - } - const data = await requestJson(selectorContracts.confluencePageSelectorContract, { + const data = await requestJson(selectorContracts.confluenceSelectorPageContract, { body: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, pageId: detailId, }, signal, diff --git a/apps/sim/hooks/selectors/providers/jira/selectors.ts b/apps/sim/hooks/selectors/providers/jira/selectors.ts index f8743641b50..b3f65a55be1 100644 --- a/apps/sim/hooks/selectors/providers/jira/selectors.ts +++ b/apps/sim/hooks/selectors/providers/jira/selectors.ts @@ -1,6 +1,5 @@ import { requestJson } from '@/lib/api/client/request' import * as selectorContracts from '@/lib/api/contracts/selectors' -import { fetchOAuthToken } from '@/hooks/selectors/helpers' import { ensureCredential, ensureDomain, @@ -16,27 +15,23 @@ export const jiraSelectors = { selectorContracts.jiraProjectsSelectorContract, selectorContracts.jiraProjectSelectorContract, ], + serverResolvedContextFields: ['domain'], staleTime: SELECTOR_STALE, getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'jira.projects', context.oauthCredential ?? 'none', - context.domain ?? 'none', search ?? '', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jira.projects') const domain = ensureDomain(context, 'jira.projects') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } const data = await requestJson(selectorContracts.jiraProjectsSelectorContract, { query: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, query: search, }, signal, @@ -50,15 +45,11 @@ export const jiraSelectors = { if (!detailId) return null const credentialId = ensureCredential(context, 'jira.projects') const domain = ensureDomain(context, 'jira.projects') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } const data = await requestJson(selectorContracts.jiraProjectSelectorContract, { body: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, projectId: detailId, }, signal, @@ -76,12 +67,12 @@ export const jiraSelectors = { selectorContracts.jiraIssuesSelectorContract, selectorContracts.jiraIssueSelectorContract, ], + serverResolvedContextFields: ['domain'], staleTime: SELECTOR_SEARCH_STALE, getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'jira.issues', context.oauthCredential ?? 'none', - context.domain ?? 'none', context.projectId ?? 'none', search ?? '', ], @@ -89,15 +80,11 @@ export const jiraSelectors = { fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jira.issues') const domain = ensureDomain(context, 'jira.issues') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } const data = await requestJson(selectorContracts.jiraIssuesSelectorContract, { query: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, projectId: context.projectId, query: search, }, @@ -118,15 +105,11 @@ export const jiraSelectors = { if (!detailId) return null const credentialId = ensureCredential(context, 'jira.issues') const domain = ensureDomain(context, 'jira.issues') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } const data = await requestJson(selectorContracts.jiraIssueSelectorContract, { body: { + credential: credentialId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, issueKeys: [detailId], }, signal, diff --git a/apps/sim/lib/api/contracts/selectors/atlassian-server-resolved.test.ts b/apps/sim/lib/api/contracts/selectors/atlassian-server-resolved.test.ts new file mode 100644 index 00000000000..2bc07cc6215 --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/atlassian-server-resolved.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + confluenceSelectorPageBodySchema, + confluenceSelectorPagesBodySchema, + confluenceSpacesSelectorBodySchema, +} from '@/lib/api/contracts/selectors/confluence' +import { jiraIssuesBodySchema, jiraProjectsQuerySchema } from '@/lib/api/contracts/selectors/jira' + +const credentialContext = { + credential: 'credential-1', + workflowId: 'workflow-1', + domain: '{{DOMAIN}}', +} + +describe('server-resolved Atlassian selector wire contracts', () => { + it('accepts literal or exact-reference domains for Jira and Confluence selectors', () => { + for (const domain of ['tenant.atlassian.net', '{{ATLASSIAN_DOMAIN}}']) { + expect(jiraProjectsQuerySchema.safeParse({ ...credentialContext, domain }).success).toBe(true) + expect( + jiraIssuesBodySchema.safeParse({ ...credentialContext, domain, issueKeys: ['SIM-1'] }) + .success + ).toBe(true) + expect( + confluenceSpacesSelectorBodySchema.safeParse({ ...credentialContext, domain }).success + ).toBe(true) + expect( + confluenceSelectorPagesBodySchema.safeParse({ ...credentialContext, domain }).success + ).toBe(true) + expect( + confluenceSelectorPageBodySchema.safeParse({ + ...credentialContext, + domain, + pageId: '1234', + }).success + ).toBe(true) + } + }) + + it('allows workflowless credential-backed Jira and Confluence selector requests', () => { + expect( + jiraProjectsQuerySchema.safeParse({ + credential: 'credential-1', + domain: '{{ATLASSIAN_DOMAIN}}', + }).success + ).toBe(true) + expect( + confluenceSpacesSelectorBodySchema.safeParse({ + credential: 'credential-1', + domain: '{{ATLASSIAN_DOMAIN}}', + }).success + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index bb6c0a4dfe3..3f581c06bdb 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -378,6 +378,17 @@ export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySc .optional(), }) +export const confluenceSelectorPagesBodySchema = credentialWorkflowDomainBodySchema.extend({ + title: optionalString, + limit: z.number().int().positive().optional().default(50), +}) + +export const confluenceSelectorPageBodySchema = credentialWorkflowDomainBodySchema + .extend({ + pageId: z.string().min(1, 'Page ID is required'), + }) + .superRefine(refineConfluencePageId) + export const confluenceSpacesSelectorContract = definePostSelector( '/api/tools/confluence/selector-spaces', confluenceSpacesSelectorBodySchema, @@ -387,6 +398,18 @@ export const confluenceSpacesSelectorContract = definePostSelector( }) ) +export const confluenceSelectorPagesContract = definePostSelector( + '/api/tools/confluence/selector-pages', + confluenceSelectorPagesBodySchema, + z.object({ files: z.array(fileOptionSchema) }) +) + +export const confluenceSelectorPageContract = definePostSelector( + '/api/tools/confluence/selector-page', + confluenceSelectorPageBodySchema, + z.object({ id: z.string(), title: z.string() }).passthrough() +) + export const confluencePagesSelectorContract = definePostSelector( '/api/tools/confluence/pages', confluencePagesBodySchema, @@ -401,6 +424,8 @@ export const confluencePageSelectorContract = definePostSelector( export const confluenceSelectorContractsByPath = { '/api/tools/confluence/selector-spaces': confluenceSpacesSelectorContract, + '/api/tools/confluence/selector-pages': confluenceSelectorPagesContract, + '/api/tools/confluence/selector-page': confluenceSelectorPageContract, '/api/tools/confluence/pages': confluencePagesSelectorContract, '/api/tools/confluence/page': confluencePageSelectorContract, } as const diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index c9cf30fd65b..8976a0a7047 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { idNameSchema, optionalString } from '@/lib/api/contracts/selectors/shared' +import { + idNameSchema, + nullableOptionalString, + optionalString, +} from '@/lib/api/contracts/selectors/shared' import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' @@ -19,16 +23,16 @@ const jiraIssueSectionSchema = z .passthrough() export const jiraProjectsQuerySchema = z.object({ + credential: z.string().min(1, 'Credential is required'), + workflowId: nullableOptionalString, domain: z.string().trim().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, query: optionalString, }) export const jiraProjectBodySchema = z.object({ + credential: z.string().min(1, 'Credential is required'), + workflowId: nullableOptionalString, domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, projectId: z.string().min(1, 'Project ID is required'), }) @@ -36,9 +40,9 @@ export const jiraProjectBodySchema = z.object({ * GET `/api/tools/jira/issues` query. */ export const jiraIssuesQuerySchema = z.object({ + credential: z.string().min(1, 'Credential is required'), + workflowId: nullableOptionalString, domain: z.string().trim().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, projectId: optionalString, manualProjectId: optionalString, query: optionalString, @@ -57,9 +61,9 @@ export const jiraIssuesQuerySchema = z.object({ }) export const jiraIssuesBodySchema = z.object({ + credential: z.string().min(1, 'Credential is required'), + workflowId: nullableOptionalString, domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, issueKeys: z.array(z.string().min(1)).default([]), }) diff --git a/apps/sim/tools/confluence/utils.ts b/apps/sim/tools/confluence/utils.ts index b18263cbcc4..a42b907f921 100644 --- a/apps/sim/tools/confluence/utils.ts +++ b/apps/sim/tools/confluence/utils.ts @@ -1,5 +1,8 @@ -import { normalizeAtlassianSiteUrl, resolveAtlassianCloudId } from '@/lib/atlassian/discovery' -import type { RetryOptions } from '@/lib/knowledge/documents/utils' +import { + type AtlassianDiscoveryRetryOptions, + normalizeAtlassianSiteUrl, + resolveAtlassianCloudId, +} from '@/lib/atlassian/discovery' const SITE_URL_SCHEME = 'https://' @@ -24,7 +27,7 @@ export function normalizeConfluenceDomainHost(domain: string): string { export function getConfluenceCloudId( domain: string, accessToken: string, - retryOptions?: RetryOptions + retryOptions?: AtlassianDiscoveryRetryOptions ): Promise { return resolveAtlassianCloudId({ domain, accessToken, product: 'Confluence', retryOptions }) }