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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/lib/sim-search/live/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Self-managed GitLab is resolved from the saved source's validated host/project i
| Calendar | CalendarList then `/calendars/{id}/events` | `/calendars/{id}/events/{eventId}` | Same-user delegation, selected calendars, event window/query |
| Slack | `POST /api/assistant.search.context` | `conversations.replies` or `files.info` preview | Member only; Slack enforces the connected user's grant |
| Jira | `POST /ex/jira/{cloudId}/rest/api/3/search/jql` | `/rest/api/3/issue/{key}` under that cloud site | Member only |
| Confluence | `/ex/confluence/{cloudId}/wiki/rest/api/search` with CQL | `/wiki/rest/api/content/{id}` | Same site, spaces, current type/status/labels, source readability |
| Confluence | `/ex/confluence/{cloudId}/wiki/rest/api/search` with CQL | v2 `/wiki/api/v2/pages/{id}` or `/blogposts/{id}` (`body-format=view`); a space reads as its homepage | Same site, spaces, current type/status/labels, source readability |
| GitHub | `/search/issues`, `/search/code`, `/search/repositories`, `/search/commits` | Issue, repository, commit, or contents endpoint for returned kind | Added repositories; installation coverage/stable IDs and code filters |
| GitLab | Configured `/api/v4/projects/{project}/search`, or supported date listing | Project issue/MR/wiki/file endpoint | Current request-local admin ACL evidence or saved CSV grants, plus content filters |
| Coda | Personal MCP `search`; REST `/apis/v1/docs` title-search compatibility | MCP read allowlist; REST compatibility document/page reads | Selected parent doc and current source-token visibility; optional Enterprise org membership |
Expand Down
155 changes: 155 additions & 0 deletions apps/sim/lib/sim-search/live/atlassian.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest'
import { readAtlassian, searchAtlassian } from '@/lib/sim-search/live/atlassian'
import { NativeSearchError } from '@/lib/sim-search/live/http'
import type { NativeClient } from '@/lib/sim-search/live/types'

const SITE = { id: 'cloud', url: 'https://acme.atlassian.net' }

/** Answers only the paths a test names, so a read through the v1 content API fails loudly. */
function client(rows: Record<string, unknown>): NativeClient & { json: ReturnType<typeof vi.fn> } {
return {
json: vi.fn(async (path: string) => {
if (path === '/oauth/token/accessible-resources') return [SITE]
if (!(path in rows)) throw new Error(`Unexpected request: ${path}`)
return rows[path]
}),
text: vi.fn(),
}
}

const v2 = '/ex/confluence/cloud/wiki/api/v2'

describe('Confluence live documents', () => {
it('reads pages and blog posts through v2, which needs only the granular read scopes', async () => {
const api = client({
[`${v2}/pages/123`]: {
id: '123',
title: 'Runbook',
body: { view: { value: '<p>Restart the <b>ingest</b> worker.</p>' } },
version: { createdAt: '2026-09-18T04:50:29.778Z' },
_links: { webui: '/spaces/ENG/pages/123/Runbook' },
},
[`${v2}/blogposts/9`]: {
id: '9',
title: 'Release notes',
body: { view: { value: '<p>Shipped search.</p>' } },
version: { createdAt: '2026-09-20T00:00:00.000Z' },
_links: { webui: '/spaces/ENG/blog/9' },
},
})
await expect(readAtlassian(api, 'confluence', '123', 'cloud', 'page')).resolves.toMatchObject({
id: '123',
kind: 'page',
title: 'Runbook',
content: expect.stringContaining('Restart the ingest worker.'),
url: 'https://acme.atlassian.net/wiki/spaces/ENG/pages/123/Runbook',
modifiedAt: '2026-09-18T04:50:29.778Z',
})
await expect(readAtlassian(api, 'confluence', '9', 'cloud', 'blogpost')).resolves.toMatchObject(
{
kind: 'blogpost',
content: expect.stringContaining('Shipped search.'),
}
)
})

it('reads a legacy reference without a kind as a blog post when no page has that id', async () => {
const api: NativeClient = {
json: vi.fn(async (path: string) => {
if (path === '/oauth/token/accessible-resources') return [SITE]
if (path === `${v2}/blogposts/9`)
return { id: '9', title: 'Release notes', body: { view: { value: '<p>Shipped.</p>' } } }
throw new NativeSearchError('unavailable', 'Provider request failed (404).', undefined, 404)
}),
text: vi.fn(),
}
await expect(readAtlassian(api, 'confluence', '9', 'cloud')).resolves.toMatchObject({
kind: 'blogpost',
content: expect.stringContaining('Shipped.'),
})
})

it('keeps a legacy reference page failure that is not a missing page', async () => {
const failure = new NativeSearchError(
'unavailable',
'Provider request failed (500).',
undefined,
500
)
Comment thread
waleedlatif1 marked this conversation as resolved.
const api: NativeClient = {
json: vi.fn(async (path: string) => {
if (path === '/oauth/token/accessible-resources') return [SITE]
if (path === `${v2}/pages/9`) throw failure
throw new Error(`Unexpected request: ${path}`)
}),
text: vi.fn(),
}
await expect(readAtlassian(api, 'confluence', '9', 'cloud')).rejects.toBe(failure)
})

it('reads a space result as its homepage, keeping the space as the document', async () => {
const api = client({
[`${v2}/spaces`]: {
results: [{ id: '7', key: 'ENG', name: 'Engineering', homepageId: '55' }],
},
[`${v2}/pages/55`]: {
id: '55',
title: 'Engineering Home',
body: { view: { value: '<p>Team charter.</p>' } },
_links: { webui: '/spaces/ENG/overview' },
},
})
await expect(readAtlassian(api, 'confluence', 'ENG', 'cloud', 'space')).resolves.toMatchObject({
id: 'ENG',
kind: 'space',
title: 'Engineering',
content: expect.stringContaining('Team charter.'),
})
})

it('records whether a search result is a page, blog post, or space so its read picks the endpoint', async () => {
const api = client({
'/ex/confluence/cloud/wiki/rest/api/search': {
results: [
{
content: {
id: '123',
type: 'page',
space: { key: 'ENG' },
title: 'Runbook',
_links: { webui: '/spaces/ENG/pages/123' },
},
},
{
content: {
id: '9',
type: 'blogpost',
title: 'Release notes',
_links: { webui: '/spaces/ENG/blog/9' },
},
},
{
entityType: 'space',
title: 'Engineering',
url: '/spaces/ENG',
space: { key: 'ENG', name: 'Engineering' },
},
],
_links: {},
},
})
const page = await searchAtlassian(api, 'confluence', {
query: 'runbook',
limit: 10,
scopes: [],
})
expect(page.documents.map(({ id, kind }) => ({ id, kind }))).toEqual([
{ id: '123', kind: 'page' },
{ id: '9', kind: 'blogpost' },
{ id: 'ENG', kind: 'space' },
])
expect(page.documents[2]?.url).toBe('https://acme.atlassian.net/wiki/spaces/ENG')
expect(page.documents[0]?.accessMetadata).toEqual({ spaceKey: 'ENG' })
expect(page.documents[2]?.accessMetadata).toEqual({ spaceKey: 'ENG' })
})
})
82 changes: 72 additions & 10 deletions apps/sim/lib/sim-search/live/atlassian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,27 @@ function issue(row: Record<string, unknown>, cloudId: string, site: string): Nat
}
}
function page(row: Record<string, unknown>, cloudId: string, site: string): NativeDocument {
if (string(row.entityType) === 'space') {
const space = object(row.space)
return {
id: string(space.key),
kind: 'space',
accessMetadata: { spaceKey: string(space.key) },
container: cloudId,
title: string(row.title) || string(space.name),
url: `${site}/wiki${string(row.url) || `/spaces/${segment(string(space.key))}`}`,
content: providerText(string(row.excerpt), 'html') || string(row.title),
modifiedAt: string(row.lastModified),
}
}
const content = Object.keys(object(row.content)).length ? object(row.content) : row
const links = object(content._links)
const version = object(content.version)
const spaceKey = string(object(content.space).key)
return {
id: string(content.id),
kind: string(content.type) === 'blogpost' ? 'blogpost' : 'page',
...(spaceKey ? { accessMetadata: { spaceKey } } : {}),
container: cloudId,
title: string(content.title) || string(row.title),
url: `${site}/wiki${string(links.webui) || `/pages/${segment(string(content.id))}`}`,
Expand Down Expand Up @@ -161,7 +177,7 @@ export async function searchAtlassian(
order
),
limit: String(input.limit),
expand: 'content.version',
expand: 'content.version,content.space',
...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}),
},
})
Expand Down Expand Up @@ -190,7 +206,8 @@ export async function readAtlassian(
client: NativeClient,
provider: 'jira' | 'confluence',
id: string,
cloudId?: string
cloudId?: string,
kind?: string
): Promise<NativeDocument> {
const site = (await sites(client)).find((row) => string(row.id) === cloudId)
if (!site || !cloudId)
Expand All @@ -205,13 +222,58 @@ export async function readAtlassian(
cloudId,
string(site.url)
)
return page(
object(
await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/content/${segment(id)}`, {
query: { expand: 'body.view,version' },
})
),
cloudId,
string(site.url)
return readConfluence(client, cloudId, string(site.url), id, kind)
}

/**
* Reads through the v2 API, whose page, blog post, and space endpoints need only the granular
* read scopes a Search connection grants; v1 content reads also need read:content-details.
* A space is read as its homepage.
*/
async function readConfluence(
client: NativeClient,
cloudId: string,
site: string,
id: string,
kind?: string
): Promise<NativeDocument> {
const api = `/ex/confluence/${segment(cloudId)}/wiki/api/v2`
let title: string | undefined
let contentId = id
let contentKind = kind === 'blogpost' ? 'blogpost' : 'page'
Comment thread
waleedlatif1 marked this conversation as resolved.
if (kind === 'space') {
const space = object(
array(object(await client.json(`${api}/spaces`, { query: { keys: id } })).results)[0]
)
if (!string(space.homepageId))
throw new NativeSearchError('unavailable', 'The Confluence space has no readable homepage.')
title = string(space.name)
contentId = string(space.homepageId)
contentKind = 'page'
}
const content = (type: string) =>
client.json(`${api}/${type}/${segment(contentId)}`, { query: { 'body-format': 'view' } })
/** A reference issued before kinds were recorded may name a blog post; its page read is a 404. */
const row = object(
kind === undefined
? await content('pages').catch((error: unknown) => {
if (error instanceof NativeSearchError && error.httpStatus === 404) {
contentKind = 'blogpost'
return content('blogposts')
}
throw error
})
: await content(contentKind === 'blogpost' ? 'blogposts' : 'pages')
)
const pageTitle = string(row.title)
return {
id,
kind: kind === 'space' ? 'space' : contentKind,
container: cloudId,
title: title || pageTitle,
url: `${site}/wiki${string(object(row._links).webui) || `/pages/${segment(contentId)}`}`,
content:
providerText(string(object(object(row.body).view).value), 'html') || title || pageTitle,
modifiedAt: string(object(row.version).createdAt) || string(row.createdAt),
}
}
16 changes: 16 additions & 0 deletions apps/sim/lib/sim-search/live/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ describe('native search network boundary', () => {
)
expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
})
it('keeps the HTTP status of a provider failure so callers can tell a missing item apart', async () => {
const client = createNativeClient({
origin: 'https://api.atlassian.com',
accessToken: 'private',
signal: new AbortController().signal,
})
for (const status of [404, 500]) {
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce(
new Response('missing', { status })
)
await expect(client.json('/ex/confluence/cloud/wiki/api/v2/pages/1')).rejects.toMatchObject({
status: 'unavailable',
httpStatus: status,
})
}
})
it('reports Retry-After without exposing the provider response body', async () => {
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
new Response('sensitive diagnostic', { status: 429, headers: { 'Retry-After': '45' } })
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/sim-search/live/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export class NativeSearchError extends Error {
constructor(
readonly status: 'reconnect' | 'rate_limited' | 'unavailable' | 'timeout',
message: string,
readonly retryAfterSeconds?: number
readonly retryAfterSeconds?: number,
/** The provider's HTTP status, when the failure is a plain non-success response. */
readonly httpStatus?: number
) {
super(message)
}
Expand Down Expand Up @@ -104,7 +106,9 @@ export function createNativeClient(input: {
'unavailable',
response.status === 400 || response.status === 422
? `The provider rejected this query (${response.status}). Check its native query syntax and supported search scope.`
: `Provider request failed (${response.status}).`
: `Provider request failed (${response.status}).`,
undefined,
response.status
)
}
return response
Expand Down
22 changes: 21 additions & 1 deletion apps/sim/lib/sim-search/live/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ describe('organization search scope enforcement', () => {
async (provider) => {
const api = client({
'/ex/jira/site/rest/api/3/issue/ENG-2': { fields: { project: { key: 'ENG' } } },
'/ex/confluence/site/wiki/rest/api/content/ENG-2': { space: { key: 'ENG' } },
'/ex/confluence/site/wiki/api/v2/pages/ENG-2': { id: 'ENG-2', spaceId: '7' },
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
})
expect(
await createPolicyVerifier(
Expand All @@ -203,6 +204,25 @@ describe('organization search scope enforcement', () => {
).toBe(false)
}
)
it('checks a Confluence search hit by the space its search response named, without requests', async () => {
const verify = createPolicyVerifier('confluence', selected(['ENG']), client({}), '')
expect(await verify({ id: '123', container: 'site', kind: 'page' }, { spaceKey: 'ENG' })).toBe(
true
)
expect(await verify({ id: '124', container: 'site', kind: 'page' }, { spaceKey: 'HR' })).toBe(
false
)
})
it('checks Confluence spaces by key and blog posts through their own endpoint', async () => {
const api = client({
'/ex/confluence/site/wiki/api/v2/blogposts/9': { id: '9', spaceId: '7' },
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
})
const verify = createPolicyVerifier('confluence', selected(['ENG']), api, '')
expect(await verify({ id: '9', container: 'site', kind: 'blogpost' })).toBe(true)
expect(await verify({ id: 'ENG', container: 'site', kind: 'space' })).toBe(true)
expect(await verify({ id: 'HR', container: 'site', kind: 'space' })).toBe(false)
})
it('checks Coda page and row document IDs, including converted URLs', async () => {
const mcp = { call: vi.fn(async () => ({ docUri: 'coda://docs/allowed' })) }
const verify = createPolicyVerifier('coda', selected(['allowed']), null, '', mcp)
Expand Down
20 changes: 13 additions & 7 deletions apps/sim/lib/sim-search/live/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,20 @@ export function createPolicyVerifier(
const project = object(object(row.fields).project)
return Boolean(project.key) && permitsResources(policy, [string(project.key)])
}
const row = object(
await json(
`/ex/confluence/${segment(document.container)}/wiki/rest/api/content/${segment(document.id)}`,
{ expand: 'space' }
/** v2 reads, like document reads, so the check needs only the granular read scopes. */
const api = `/ex/confluence/${segment(document.container)}/wiki/api/v2`
/** The search response names each hit's space; only reads without that evidence look it up. */
let spaceKey = document.kind === 'space' ? document.id : string(providerMetadata?.spaceKey)
if (!spaceKey) {
const row = object(
await json(
`${api}/${document.kind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(document.id)}`
)
)
)
const space = object(row.space)
return Boolean(space.key) && permitsResources(policy, [string(space.key)])
if (!string(row.spaceId)) return false
spaceKey = string(object(await json(`${api}/spaces/${segment(string(row.spaceId))}`)).key)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
return Boolean(spaceKey) && permitsResources(policy, [spaceKey])
}
if (provider === 'coda') {
if (!restricted) return true
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/sim-search/live/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const LIVE_SEARCH_PROVIDERS = {
},
search: (client, input) => searchAtlassian(client, 'confluence', input),
read: (client, reference) =>
readAtlassian(client, 'confluence', reference.id, reference.container),
readAtlassian(client, 'confluence', reference.id, reference.container, reference.kind),
},
github: {
guide: {
Expand Down
Loading