Skip to content

Commit a0c93d6

Browse files
authored
fix(search): read Confluence pages through the v2 API the Search grant allows (#8321)
* fix(search): read Confluence pages through the v2 API the Search grant allows * fix(search): use search-response spaces for verification and fall back for legacy blog-post references * fix(search): fall back to blog posts only when the legacy page is missing * test(search): check that provider failures keep their HTTP status
1 parent 2b01631 commit a0c93d6

8 files changed

Lines changed: 285 additions & 22 deletions

File tree

‎apps/sim/lib/sim-search/live/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Self-managed GitLab is resolved from the saved source's validated host/project i
123123
| Calendar | CalendarList then `/calendars/{id}/events` | `/calendars/{id}/events/{eventId}` | Same-user delegation, selected calendars, event window/query |
124124
| Slack | `POST /api/assistant.search.context` | `conversations.replies` or `files.info` preview | Member only; Slack enforces the connected user's grant |
125125
| Jira | `POST /ex/jira/{cloudId}/rest/api/3/search/jql` | `/rest/api/3/issue/{key}` under that cloud site | Member only |
126-
| Confluence | `/ex/confluence/{cloudId}/wiki/rest/api/search` with CQL | `/wiki/rest/api/content/{id}` | Same site, spaces, current type/status/labels, source readability |
126+
| 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 |
127127
| 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 |
128128
| 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 |
129129
| 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 |
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { readAtlassian, searchAtlassian } from '@/lib/sim-search/live/atlassian'
3+
import { NativeSearchError } from '@/lib/sim-search/live/http'
4+
import type { NativeClient } from '@/lib/sim-search/live/types'
5+
6+
const SITE = { id: 'cloud', url: 'https://acme.atlassian.net' }
7+
8+
/** Answers only the paths a test names, so a read through the v1 content API fails loudly. */
9+
function client(rows: Record<string, unknown>): NativeClient & { json: ReturnType<typeof vi.fn> } {
10+
return {
11+
json: vi.fn(async (path: string) => {
12+
if (path === '/oauth/token/accessible-resources') return [SITE]
13+
if (!(path in rows)) throw new Error(`Unexpected request: ${path}`)
14+
return rows[path]
15+
}),
16+
text: vi.fn(),
17+
}
18+
}
19+
20+
const v2 = '/ex/confluence/cloud/wiki/api/v2'
21+
22+
describe('Confluence live documents', () => {
23+
it('reads pages and blog posts through v2, which needs only the granular read scopes', async () => {
24+
const api = client({
25+
[`${v2}/pages/123`]: {
26+
id: '123',
27+
title: 'Runbook',
28+
body: { view: { value: '<p>Restart the <b>ingest</b> worker.</p>' } },
29+
version: { createdAt: '2026-09-18T04:50:29.778Z' },
30+
_links: { webui: '/spaces/ENG/pages/123/Runbook' },
31+
},
32+
[`${v2}/blogposts/9`]: {
33+
id: '9',
34+
title: 'Release notes',
35+
body: { view: { value: '<p>Shipped search.</p>' } },
36+
version: { createdAt: '2026-09-20T00:00:00.000Z' },
37+
_links: { webui: '/spaces/ENG/blog/9' },
38+
},
39+
})
40+
await expect(readAtlassian(api, 'confluence', '123', 'cloud', 'page')).resolves.toMatchObject({
41+
id: '123',
42+
kind: 'page',
43+
title: 'Runbook',
44+
content: expect.stringContaining('Restart the ingest worker.'),
45+
url: 'https://acme.atlassian.net/wiki/spaces/ENG/pages/123/Runbook',
46+
modifiedAt: '2026-09-18T04:50:29.778Z',
47+
})
48+
await expect(readAtlassian(api, 'confluence', '9', 'cloud', 'blogpost')).resolves.toMatchObject(
49+
{
50+
kind: 'blogpost',
51+
content: expect.stringContaining('Shipped search.'),
52+
}
53+
)
54+
})
55+
56+
it('reads a legacy reference without a kind as a blog post when no page has that id', async () => {
57+
const api: NativeClient = {
58+
json: vi.fn(async (path: string) => {
59+
if (path === '/oauth/token/accessible-resources') return [SITE]
60+
if (path === `${v2}/blogposts/9`)
61+
return { id: '9', title: 'Release notes', body: { view: { value: '<p>Shipped.</p>' } } }
62+
throw new NativeSearchError('unavailable', 'Provider request failed (404).', undefined, 404)
63+
}),
64+
text: vi.fn(),
65+
}
66+
await expect(readAtlassian(api, 'confluence', '9', 'cloud')).resolves.toMatchObject({
67+
kind: 'blogpost',
68+
content: expect.stringContaining('Shipped.'),
69+
})
70+
})
71+
72+
it('keeps a legacy reference page failure that is not a missing page', async () => {
73+
const failure = new NativeSearchError(
74+
'unavailable',
75+
'Provider request failed (500).',
76+
undefined,
77+
500
78+
)
79+
const api: NativeClient = {
80+
json: vi.fn(async (path: string) => {
81+
if (path === '/oauth/token/accessible-resources') return [SITE]
82+
if (path === `${v2}/pages/9`) throw failure
83+
throw new Error(`Unexpected request: ${path}`)
84+
}),
85+
text: vi.fn(),
86+
}
87+
await expect(readAtlassian(api, 'confluence', '9', 'cloud')).rejects.toBe(failure)
88+
})
89+
90+
it('reads a space result as its homepage, keeping the space as the document', async () => {
91+
const api = client({
92+
[`${v2}/spaces`]: {
93+
results: [{ id: '7', key: 'ENG', name: 'Engineering', homepageId: '55' }],
94+
},
95+
[`${v2}/pages/55`]: {
96+
id: '55',
97+
title: 'Engineering Home',
98+
body: { view: { value: '<p>Team charter.</p>' } },
99+
_links: { webui: '/spaces/ENG/overview' },
100+
},
101+
})
102+
await expect(readAtlassian(api, 'confluence', 'ENG', 'cloud', 'space')).resolves.toMatchObject({
103+
id: 'ENG',
104+
kind: 'space',
105+
title: 'Engineering',
106+
content: expect.stringContaining('Team charter.'),
107+
})
108+
})
109+
110+
it('records whether a search result is a page, blog post, or space so its read picks the endpoint', async () => {
111+
const api = client({
112+
'/ex/confluence/cloud/wiki/rest/api/search': {
113+
results: [
114+
{
115+
content: {
116+
id: '123',
117+
type: 'page',
118+
space: { key: 'ENG' },
119+
title: 'Runbook',
120+
_links: { webui: '/spaces/ENG/pages/123' },
121+
},
122+
},
123+
{
124+
content: {
125+
id: '9',
126+
type: 'blogpost',
127+
title: 'Release notes',
128+
_links: { webui: '/spaces/ENG/blog/9' },
129+
},
130+
},
131+
{
132+
entityType: 'space',
133+
title: 'Engineering',
134+
url: '/spaces/ENG',
135+
space: { key: 'ENG', name: 'Engineering' },
136+
},
137+
],
138+
_links: {},
139+
},
140+
})
141+
const page = await searchAtlassian(api, 'confluence', {
142+
query: 'runbook',
143+
limit: 10,
144+
scopes: [],
145+
})
146+
expect(page.documents.map(({ id, kind }) => ({ id, kind }))).toEqual([
147+
{ id: '123', kind: 'page' },
148+
{ id: '9', kind: 'blogpost' },
149+
{ id: 'ENG', kind: 'space' },
150+
])
151+
expect(page.documents[2]?.url).toBe('https://acme.atlassian.net/wiki/spaces/ENG')
152+
expect(page.documents[0]?.accessMetadata).toEqual({ spaceKey: 'ENG' })
153+
expect(page.documents[2]?.accessMetadata).toEqual({ spaceKey: 'ENG' })
154+
})
155+
})

‎apps/sim/lib/sim-search/live/atlassian.ts‎

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,27 @@ function issue(row: Record<string, unknown>, cloudId: string, site: string): Nat
6666
}
6767
}
6868
function page(row: Record<string, unknown>, cloudId: string, site: string): NativeDocument {
69+
if (string(row.entityType) === 'space') {
70+
const space = object(row.space)
71+
return {
72+
id: string(space.key),
73+
kind: 'space',
74+
accessMetadata: { spaceKey: string(space.key) },
75+
container: cloudId,
76+
title: string(row.title) || string(space.name),
77+
url: `${site}/wiki${string(row.url) || `/spaces/${segment(string(space.key))}`}`,
78+
content: providerText(string(row.excerpt), 'html') || string(row.title),
79+
modifiedAt: string(row.lastModified),
80+
}
81+
}
6982
const content = Object.keys(object(row.content)).length ? object(row.content) : row
7083
const links = object(content._links)
7184
const version = object(content.version)
85+
const spaceKey = string(object(content.space).key)
7286
return {
7387
id: string(content.id),
88+
kind: string(content.type) === 'blogpost' ? 'blogpost' : 'page',
89+
...(spaceKey ? { accessMetadata: { spaceKey } } : {}),
7490
container: cloudId,
7591
title: string(content.title) || string(row.title),
7692
url: `${site}/wiki${string(links.webui) || `/pages/${segment(string(content.id))}`}`,
@@ -161,7 +177,7 @@ export async function searchAtlassian(
161177
order
162178
),
163179
limit: String(input.limit),
164-
expand: 'content.version',
180+
expand: 'content.version,content.space',
165181
...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}),
166182
},
167183
})
@@ -190,7 +206,8 @@ export async function readAtlassian(
190206
client: NativeClient,
191207
provider: 'jira' | 'confluence',
192208
id: string,
193-
cloudId?: string
209+
cloudId?: string,
210+
kind?: string
194211
): Promise<NativeDocument> {
195212
const site = (await sites(client)).find((row) => string(row.id) === cloudId)
196213
if (!site || !cloudId)
@@ -205,13 +222,58 @@ export async function readAtlassian(
205222
cloudId,
206223
string(site.url)
207224
)
208-
return page(
209-
object(
210-
await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/content/${segment(id)}`, {
211-
query: { expand: 'body.view,version' },
212-
})
213-
),
214-
cloudId,
215-
string(site.url)
225+
return readConfluence(client, cloudId, string(site.url), id, kind)
226+
}
227+
228+
/**
229+
* Reads through the v2 API, whose page, blog post, and space endpoints need only the granular
230+
* read scopes a Search connection grants; v1 content reads also need read:content-details.
231+
* A space is read as its homepage.
232+
*/
233+
async function readConfluence(
234+
client: NativeClient,
235+
cloudId: string,
236+
site: string,
237+
id: string,
238+
kind?: string
239+
): Promise<NativeDocument> {
240+
const api = `/ex/confluence/${segment(cloudId)}/wiki/api/v2`
241+
let title: string | undefined
242+
let contentId = id
243+
let contentKind = kind === 'blogpost' ? 'blogpost' : 'page'
244+
if (kind === 'space') {
245+
const space = object(
246+
array(object(await client.json(`${api}/spaces`, { query: { keys: id } })).results)[0]
247+
)
248+
if (!string(space.homepageId))
249+
throw new NativeSearchError('unavailable', 'The Confluence space has no readable homepage.')
250+
title = string(space.name)
251+
contentId = string(space.homepageId)
252+
contentKind = 'page'
253+
}
254+
const content = (type: string) =>
255+
client.json(`${api}/${type}/${segment(contentId)}`, { query: { 'body-format': 'view' } })
256+
/** A reference issued before kinds were recorded may name a blog post; its page read is a 404. */
257+
const row = object(
258+
kind === undefined
259+
? await content('pages').catch((error: unknown) => {
260+
if (error instanceof NativeSearchError && error.httpStatus === 404) {
261+
contentKind = 'blogpost'
262+
return content('blogposts')
263+
}
264+
throw error
265+
})
266+
: await content(contentKind === 'blogpost' ? 'blogposts' : 'pages')
216267
)
268+
const pageTitle = string(row.title)
269+
return {
270+
id,
271+
kind: kind === 'space' ? 'space' : contentKind,
272+
container: cloudId,
273+
title: title || pageTitle,
274+
url: `${site}/wiki${string(object(row._links).webui) || `/pages/${segment(contentId)}`}`,
275+
content:
276+
providerText(string(object(object(row.body).view).value), 'html') || title || pageTitle,
277+
modifiedAt: string(object(row.version).createdAt) || string(row.createdAt),
278+
}
217279
}

‎apps/sim/lib/sim-search/live/http.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ describe('native search network boundary', () => {
7474
)
7575
expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
7676
})
77+
it('keeps the HTTP status of a provider failure so callers can tell a missing item apart', async () => {
78+
const client = createNativeClient({
79+
origin: 'https://api.atlassian.com',
80+
accessToken: 'private',
81+
signal: new AbortController().signal,
82+
})
83+
for (const status of [404, 500]) {
84+
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce(
85+
new Response('missing', { status })
86+
)
87+
await expect(client.json('/ex/confluence/cloud/wiki/api/v2/pages/1')).rejects.toMatchObject({
88+
status: 'unavailable',
89+
httpStatus: status,
90+
})
91+
}
92+
})
7793
it('reports Retry-After without exposing the provider response body', async () => {
7894
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
7995
new Response('sensitive diagnostic', { status: 429, headers: { 'Retry-After': '45' } })

‎apps/sim/lib/sim-search/live/http.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export class NativeSearchError extends Error {
99
constructor(
1010
readonly status: 'reconnect' | 'rate_limited' | 'unavailable' | 'timeout',
1111
message: string,
12-
readonly retryAfterSeconds?: number
12+
readonly retryAfterSeconds?: number,
13+
/** The provider's HTTP status, when the failure is a plain non-success response. */
14+
readonly httpStatus?: number
1315
) {
1416
super(message)
1517
}
@@ -104,7 +106,9 @@ export function createNativeClient(input: {
104106
'unavailable',
105107
response.status === 400 || response.status === 422
106108
? `The provider rejected this query (${response.status}). Check its native query syntax and supported search scope.`
107-
: `Provider request failed (${response.status}).`
109+
: `Provider request failed (${response.status}).`,
110+
undefined,
111+
response.status
108112
)
109113
}
110114
return response

‎apps/sim/lib/sim-search/live/policy.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ describe('organization search scope enforcement', () => {
183183
async (provider) => {
184184
const api = client({
185185
'/ex/jira/site/rest/api/3/issue/ENG-2': { fields: { project: { key: 'ENG' } } },
186-
'/ex/confluence/site/wiki/rest/api/content/ENG-2': { space: { key: 'ENG' } },
186+
'/ex/confluence/site/wiki/api/v2/pages/ENG-2': { id: 'ENG-2', spaceId: '7' },
187+
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
187188
})
188189
expect(
189190
await createPolicyVerifier(
@@ -203,6 +204,25 @@ describe('organization search scope enforcement', () => {
203204
).toBe(false)
204205
}
205206
)
207+
it('checks a Confluence search hit by the space its search response named, without requests', async () => {
208+
const verify = createPolicyVerifier('confluence', selected(['ENG']), client({}), '')
209+
expect(await verify({ id: '123', container: 'site', kind: 'page' }, { spaceKey: 'ENG' })).toBe(
210+
true
211+
)
212+
expect(await verify({ id: '124', container: 'site', kind: 'page' }, { spaceKey: 'HR' })).toBe(
213+
false
214+
)
215+
})
216+
it('checks Confluence spaces by key and blog posts through their own endpoint', async () => {
217+
const api = client({
218+
'/ex/confluence/site/wiki/api/v2/blogposts/9': { id: '9', spaceId: '7' },
219+
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
220+
})
221+
const verify = createPolicyVerifier('confluence', selected(['ENG']), api, '')
222+
expect(await verify({ id: '9', container: 'site', kind: 'blogpost' })).toBe(true)
223+
expect(await verify({ id: 'ENG', container: 'site', kind: 'space' })).toBe(true)
224+
expect(await verify({ id: 'HR', container: 'site', kind: 'space' })).toBe(false)
225+
})
206226
it('checks Coda page and row document IDs, including converted URLs', async () => {
207227
const mcp = { call: vi.fn(async () => ({ docUri: 'coda://docs/allowed' })) }
208228
const verify = createPolicyVerifier('coda', selected(['allowed']), null, '', mcp)

‎apps/sim/lib/sim-search/live/policy.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,20 @@ export function createPolicyVerifier(
200200
const project = object(object(row.fields).project)
201201
return Boolean(project.key) && permitsResources(policy, [string(project.key)])
202202
}
203-
const row = object(
204-
await json(
205-
`/ex/confluence/${segment(document.container)}/wiki/rest/api/content/${segment(document.id)}`,
206-
{ expand: 'space' }
203+
/** v2 reads, like document reads, so the check needs only the granular read scopes. */
204+
const api = `/ex/confluence/${segment(document.container)}/wiki/api/v2`
205+
/** The search response names each hit's space; only reads without that evidence look it up. */
206+
let spaceKey = document.kind === 'space' ? document.id : string(providerMetadata?.spaceKey)
207+
if (!spaceKey) {
208+
const row = object(
209+
await json(
210+
`${api}/${document.kind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(document.id)}`
211+
)
207212
)
208-
)
209-
const space = object(row.space)
210-
return Boolean(space.key) && permitsResources(policy, [string(space.key)])
213+
if (!string(row.spaceId)) return false
214+
spaceKey = string(object(await json(`${api}/spaces/${segment(string(row.spaceId))}`)).key)
215+
}
216+
return Boolean(spaceKey) && permitsResources(policy, [spaceKey])
211217
}
212218
if (provider === 'coda') {
213219
if (!restricted) return true

‎apps/sim/lib/sim-search/live/providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const LIVE_SEARCH_PROVIDERS = {
139139
},
140140
search: (client, input) => searchAtlassian(client, 'confluence', input),
141141
read: (client, reference) =>
142-
readAtlassian(client, 'confluence', reference.id, reference.container),
142+
readAtlassian(client, 'confluence', reference.id, reference.container, reference.kind),
143143
},
144144
github: {
145145
guide: {

0 commit comments

Comments
 (0)