From ba8c7d5c74d9da66f77caec16613544ac043a145 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 20:31:15 -0700 Subject: [PATCH 1/4] fix(search): read Confluence pages through the v2 API the Search grant allows --- apps/sim/lib/sim-search/live/README.md | 2 +- .../sim/lib/sim-search/live/atlassian.test.ts | 119 ++++++++++++++++++ apps/sim/lib/sim-search/live/atlassian.ts | 69 ++++++++-- apps/sim/lib/sim-search/live/policy.test.ts | 13 +- apps/sim/lib/sim-search/live/policy.ts | 19 +-- apps/sim/lib/sim-search/live/providers.ts | 2 +- 6 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 apps/sim/lib/sim-search/live/atlassian.test.ts diff --git a/apps/sim/lib/sim-search/live/README.md b/apps/sim/lib/sim-search/live/README.md index c67b634c5d9..548d2d91047 100644 --- a/apps/sim/lib/sim-search/live/README.md +++ b/apps/sim/lib/sim-search/live/README.md @@ -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 | diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts new file mode 100644 index 00000000000..8220cfe5cc6 --- /dev/null +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { readAtlassian, searchAtlassian } from '@/lib/sim-search/live/atlassian' +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): NativeClient & { json: ReturnType } { + 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: '

Restart the ingest worker.

' } }, + 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: '

Shipped search.

' } }, + 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.'), + } + ) + expect(api.json).toHaveBeenCalledWith(`${v2}/pages/123`, { query: { 'body-format': 'view' } }) + }) + + 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: '

Team charter.

' } }, + _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.'), + }) + expect(api.json).toHaveBeenCalledWith(`${v2}/spaces`, { query: { keys: 'ENG' } }) + }) + + 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', + 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') + }) +}) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index c5a610c6b45..26132a75294 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -66,11 +66,24 @@ function issue(row: Record, cloudId: string, site: string): Nat } } function page(row: Record, cloudId: string, site: string): NativeDocument { + if (string(row.entityType) === 'space') { + const space = object(row.space) + return { + id: string(space.key), + kind: 'space', + 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) return { id: string(content.id), + kind: string(content.type) === 'blogpost' ? 'blogpost' : 'page', container: cloudId, title: string(content.title) || string(row.title), url: `${site}/wiki${string(links.webui) || `/pages/${segment(string(content.id))}`}`, @@ -190,7 +203,8 @@ export async function readAtlassian( client: NativeClient, provider: 'jira' | 'confluence', id: string, - cloudId?: string + cloudId?: string, + kind?: string ): Promise { const site = (await sites(client)).find((row) => string(row.id) === cloudId) if (!site || !cloudId) @@ -205,13 +219,50 @@ 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 { + const api = `/ex/confluence/${segment(cloudId)}/wiki/api/v2` + let title: string | undefined + let contentId = id + let contentKind = kind === 'blogpost' ? 'blogpost' : 'page' + 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 row = object( + await client.json( + `${api}/${contentKind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(contentId)}`, + { query: { 'body-format': 'view' } } + ) ) + 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), + } } diff --git a/apps/sim/lib/sim-search/live/policy.test.ts b/apps/sim/lib/sim-search/live/policy.test.ts index 6916c4e2d00..374cb099622 100644 --- a/apps/sim/lib/sim-search/live/policy.test.ts +++ b/apps/sim/lib/sim-search/live/policy.test.ts @@ -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( @@ -203,6 +204,16 @@ describe('organization search scope enforcement', () => { ).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) diff --git a/apps/sim/lib/sim-search/live/policy.ts b/apps/sim/lib/sim-search/live/policy.ts index 43ec5a3c339..3d9ef22aaa5 100644 --- a/apps/sim/lib/sim-search/live/policy.ts +++ b/apps/sim/lib/sim-search/live/policy.ts @@ -200,14 +200,19 @@ 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` + let spaceKey = document.kind === 'space' ? document.id : '' + 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) + } + return Boolean(spaceKey) && permitsResources(policy, [spaceKey]) } if (provider === 'coda') { if (!restricted) return true diff --git a/apps/sim/lib/sim-search/live/providers.ts b/apps/sim/lib/sim-search/live/providers.ts index 53f5a13ccd7..c055cd233ab 100644 --- a/apps/sim/lib/sim-search/live/providers.ts +++ b/apps/sim/lib/sim-search/live/providers.ts @@ -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: { From 727875ab983012c323739f4de7e510c7e2fdb12f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 01:32:16 -0700 Subject: [PATCH 2/4] fix(search): use search-response spaces for verification and fall back for legacy blog-post references --- .../sim/lib/sim-search/live/atlassian.test.ts | 22 +++++++++++++++++-- apps/sim/lib/sim-search/live/atlassian.ts | 21 +++++++++++++----- apps/sim/lib/sim-search/live/policy.test.ts | 9 ++++++++ apps/sim/lib/sim-search/live/policy.ts | 3 ++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 8220cfe5cc6..a3c49aa3d17 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -1,5 +1,6 @@ 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' } @@ -50,7 +51,22 @@ describe('Confluence live documents', () => { content: expect.stringContaining('Shipped search.'), } ) - expect(api.json).toHaveBeenCalledWith(`${v2}/pages/123`, { query: { 'body-format': 'view' } }) + }) + + 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: '

Shipped.

' } } } + throw new NativeSearchError('unavailable', 'Provider request failed (404).') + }), + text: vi.fn(), + } + await expect(readAtlassian(api, 'confluence', '9', 'cloud')).resolves.toMatchObject({ + kind: 'blogpost', + content: expect.stringContaining('Shipped.'), + }) }) it('reads a space result as its homepage, keeping the space as the document', async () => { @@ -71,7 +87,6 @@ describe('Confluence live documents', () => { title: 'Engineering', content: expect.stringContaining('Team charter.'), }) - expect(api.json).toHaveBeenCalledWith(`${v2}/spaces`, { query: { keys: 'ENG' } }) }) it('records whether a search result is a page, blog post, or space so its read picks the endpoint', async () => { @@ -82,6 +97,7 @@ describe('Confluence live documents', () => { content: { id: '123', type: 'page', + space: { key: 'ENG' }, title: 'Runbook', _links: { webui: '/spaces/ENG/pages/123' }, }, @@ -115,5 +131,7 @@ describe('Confluence live documents', () => { { 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' }) }) }) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 26132a75294..d4472d17449 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -71,6 +71,7 @@ function page(row: Record, cloudId: string, site: string): Nati 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))}`}`, @@ -81,9 +82,11 @@ function page(row: Record, cloudId: string, site: string): Nati 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))}`}`, @@ -174,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 } : {}), }, }) @@ -248,11 +251,19 @@ async function readConfluence( 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( - await client.json( - `${api}/${contentKind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(contentId)}`, - { query: { 'body-format': 'view' } } - ) + kind === undefined + ? await content('pages').catch((error: unknown) => { + if (error instanceof NativeSearchError && error.status === 'unavailable') { + contentKind = 'blogpost' + return content('blogposts') + } + throw error + }) + : await content(contentKind === 'blogpost' ? 'blogposts' : 'pages') ) const pageTitle = string(row.title) return { diff --git a/apps/sim/lib/sim-search/live/policy.test.ts b/apps/sim/lib/sim-search/live/policy.test.ts index 374cb099622..491c3edecc9 100644 --- a/apps/sim/lib/sim-search/live/policy.test.ts +++ b/apps/sim/lib/sim-search/live/policy.test.ts @@ -204,6 +204,15 @@ 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' }, diff --git a/apps/sim/lib/sim-search/live/policy.ts b/apps/sim/lib/sim-search/live/policy.ts index 3d9ef22aaa5..026a1419efa 100644 --- a/apps/sim/lib/sim-search/live/policy.ts +++ b/apps/sim/lib/sim-search/live/policy.ts @@ -202,7 +202,8 @@ export function createPolicyVerifier( } /** v2 reads, like document reads, so the check needs only the granular read scopes. */ const api = `/ex/confluence/${segment(document.container)}/wiki/api/v2` - let spaceKey = document.kind === 'space' ? document.id : '' + /** 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( From 77593c62e7acde0285f2657689e0442ff70b228a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 02:26:43 -0700 Subject: [PATCH 3/4] fix(search): fall back to blog posts only when the legacy page is missing --- .../sim/lib/sim-search/live/atlassian.test.ts | 20 ++++++++++++++++++- apps/sim/lib/sim-search/live/atlassian.ts | 2 +- apps/sim/lib/sim-search/live/http.ts | 8 ++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index a3c49aa3d17..fda5330dcdb 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -59,7 +59,7 @@ describe('Confluence live documents', () => { if (path === '/oauth/token/accessible-resources') return [SITE] if (path === `${v2}/blogposts/9`) return { id: '9', title: 'Release notes', body: { view: { value: '

Shipped.

' } } } - throw new NativeSearchError('unavailable', 'Provider request failed (404).') + throw new NativeSearchError('unavailable', 'Provider request failed (404).', undefined, 404) }), text: vi.fn(), } @@ -69,6 +69,24 @@ describe('Confluence live documents', () => { }) }) + 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 + ) + 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`]: { diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index d4472d17449..e9765e3c1b3 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -257,7 +257,7 @@ async function readConfluence( const row = object( kind === undefined ? await content('pages').catch((error: unknown) => { - if (error instanceof NativeSearchError && error.status === 'unavailable') { + if (error instanceof NativeSearchError && error.httpStatus === 404) { contentKind = 'blogpost' return content('blogposts') } diff --git a/apps/sim/lib/sim-search/live/http.ts b/apps/sim/lib/sim-search/live/http.ts index 298c02048df..f53d71aa086 100644 --- a/apps/sim/lib/sim-search/live/http.ts +++ b/apps/sim/lib/sim-search/live/http.ts @@ -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) } @@ -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 From a9480f3629f4d15b7f97bc3f034fef01166a8176 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 03:02:32 -0700 Subject: [PATCH 4/4] test(search): check that provider failures keep their HTTP status --- apps/sim/lib/sim-search/live/http.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/sim/lib/sim-search/live/http.test.ts b/apps/sim/lib/sim-search/live/http.test.ts index e06e4753776..4d4efa0cd49 100644 --- a/apps/sim/lib/sim-search/live/http.test.ts +++ b/apps/sim/lib/sim-search/live/http.test.ts @@ -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' } })