Skip to content

Commit 1fd92a6

Browse files
committed
fix(search): recover GitHub installation setup and verify ownership
1 parent 77cf710 commit 1fd92a6

13 files changed

Lines changed: 393 additions & 24 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,14 @@ Expand **Permissions → Repository permissions**. Set **Contents → Access: Re
163163

164164
Expand **Account permissions** and set **Email addresses → Access: Read-only**.
165165

166+
Expand **Organization permissions** and set **Members → Access: Read-only**. Sim uses this to verify that the person connecting an organization installation is an organization owner. Existing installations must approve this permission update; repository read access alone cannot verify ownership.
167+
166168
<Image className="mx-auto h-auto w-full max-w-md" src="/static/search/github-app-email-permission.jpg" alt="GitHub account permissions with only Email addresses selected for Read-only access" width={768} height={929} />
167169

168170
| Permission area | Permission | Access |
169171
|---|---|---|
170172
| Repository | Contents | Read-only |
173+
| Organization | Members | Read-only |
171174
| Repository | Metadata | Read-only |
172175
| Account | Email addresses | Read-only |
173176

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,8 @@ export function AddConnectorModal({
828828
connecting={githubSetup.pending}
829829
connectionError={githubSetup.error}
830830
onCancel={githubSetup.cancel}
831+
onCheckConnection={() => void githubSetup.checkConnection()}
832+
isChecking={githubSetup.isChecking}
831833
onChange={(credentialId) => {
832834
if (credentialId !== installationCredentialId)
833835
handleFieldChange('repository', '')

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ export function ConnectorSettingsFields({
300300
: undefined
301301
}
302302
onCancel={githubSetup.cancel}
303+
onCheckConnection={() => void githubSetup.checkConnection()}
304+
isChecking={githubSetup.isChecking}
303305
onChange={onContentCredentialChange}
304306
>
305307
{(accessDirty || canReenableMemberSync) && (

‎apps/sim/connectors/github/installation-connection-field.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface GitHubInstallationConnectionFieldProps {
1818
connectionError?: string | null
1919
hint?: string
2020
onCancel?: () => void
21+
onCheckConnection: () => void
22+
isChecking?: boolean
2123
children?: ReactNode
2224
}
2325

@@ -35,13 +37,17 @@ export function GitHubInstallationConnectionField({
3537
connectionError,
3638
hint,
3739
onCancel,
40+
onCheckConnection,
41+
isChecking = false,
3842
children,
3943
}: GitHubInstallationConnectionFieldProps) {
4044
return (
4145
<ChipModalField type='custom' title='GitHub' hint={hint}>
4246
{connecting ? (
4347
<div className='flex items-center gap-2'>
44-
<Chip disabled>Connecting GitHub…</Chip>
48+
<Chip disabled={isChecking} onClick={onCheckConnection}>
49+
{isChecking ? 'Checking GitHub…' : 'Check connection'}
50+
</Chip>
4551
<Chip aria-label='Cancel GitHub connection' onClick={onCancel}>
4652
Cancel
4753
</Chip>

‎apps/sim/hooks/use-github-installation-setup-cache.test.tsx‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe('GitHub setup credential cache reconciliation', () => {
3434
let browsing: ReturnType<typeof useOAuthCredentials>
3535
let unrelated: ReturnType<typeof useOAuthCredentials>
3636
let completed: boolean
37+
let completeOnRecheck: boolean
3738
let channels: Array<{ onmessage: ((event: MessageEvent<unknown>) => void) | null }>
3839

3940
function Probe() {
@@ -73,6 +74,7 @@ describe('GitHub setup credential cache reconciliation', () => {
7374
mocks.request.mockReset()
7475
mocks.connected.mockReset()
7576
completed = false
77+
completeOnRecheck = false
7678
channels = []
7779
vi.stubGlobal(
7880
'BroadcastChannel',
@@ -109,8 +111,15 @@ describe('GitHub setup credential cache reconciliation', () => {
109111
],
110112
}
111113
}
112-
if (contract === startGitHubSearchSetupContract)
113-
return { success: true, url: 'https://github.com/apps/sim/installations/new' }
114+
if (contract === startGitHubSearchSetupContract) {
115+
if (completeOnRecheck) completed = true
116+
return {
117+
success: true,
118+
url: completed
119+
? `${window.location.origin}/credential-groups/complete`
120+
: 'https://github.com/apps/sim/installations/new',
121+
}
122+
}
114123
if (contract === readGitHubSearchSetupContract)
115124
return {
116125
success: true,
@@ -135,6 +144,28 @@ describe('GitHub setup credential cache reconciliation', () => {
135144
vi.unstubAllGlobals()
136145
})
137146

147+
it('unlocks the account selection after rechecking GitHub without a callback or broadcast', async () => {
148+
await act(async () =>
149+
root.render(
150+
<QueryClientProvider client={client}>
151+
<Probe />
152+
</QueryClientProvider>
153+
)
154+
)
155+
await flush()
156+
await act(async () => setup.connect())
157+
await flush()
158+
completeOnRecheck = true
159+
await act(async () => window.dispatchEvent(new Event('focus')))
160+
await flush()
161+
await flush()
162+
expect(mocks.connected).toHaveBeenCalledExactlyOnceWith('installation-new')
163+
expect(setup.pending).toBe(false)
164+
expect(setup.error).toBeNull()
165+
expect(indexing.data?.map(({ id }) => id)).toContain('installation-new')
166+
expect(unrelated.data?.map(({ id }) => id)).toEqual(['org-2-existing'])
167+
})
168+
138169
it('refreshes mounted Add and Settings lists on completion without a visibility event', async () => {
139170
await act(async () =>
140171
root.render(

‎apps/sim/hooks/use-github-installation-setup.test.tsx‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,51 @@ describe('GitHub installation setup handoff', () => {
119119
expect(mocks.start).toHaveBeenCalledOnce()
120120
expect(tab.focus).toHaveBeenCalledOnce()
121121
})
122+
it('rechecks the same attempt on return to Sim without relying on a GitHub redirect', async () => {
123+
await act(async () => current.connect())
124+
mocks.start.mockResolvedValue({ url: `${window.location.origin}/credential-groups/complete` })
125+
await act(async () => window.dispatchEvent(new Event('focus')))
126+
expect(mocks.start).toHaveBeenLastCalledWith(mocks.start.mock.calls[0][0])
127+
expect(mocks.refetch).toHaveBeenCalledOnce()
128+
expect(mocks.connected).not.toHaveBeenCalled()
129+
mocks.status = {
130+
status: 'completed',
131+
credential: { id: 'installation-1', displayName: 'Acme' },
132+
}
133+
act(() => root.render(<Probe />))
134+
expect(current.pending).toBe(false)
135+
expect(mocks.connected).toHaveBeenCalledExactlyOnceWith('installation-1')
136+
})
137+
it('leaves GitHub in place if installation is unfinished and exposes a retry', async () => {
138+
await act(async () => current.connect())
139+
const initialUrl = tab.location.href
140+
await act(async () => current.checkConnection())
141+
expect(tab.location.href).toBe(initialUrl)
142+
expect(current.error).toContain('Finish setup on GitHub')
143+
expect(current.pending).toBe(true)
144+
expect(mocks.cancel).not.toHaveBeenCalled()
145+
})
146+
it('ignores an installation recheck that finishes after cancellation', async () => {
147+
await act(async () => current.connect())
148+
let resolve!: (value: { url: string }) => void
149+
mocks.start.mockReturnValue(
150+
new Promise((done) => {
151+
resolve = done
152+
})
153+
)
154+
let checking!: Promise<void>
155+
act(() => {
156+
checking = current.checkConnection()
157+
})
158+
act(() => current.cancel())
159+
await act(async () => {
160+
resolve({ url: `${window.location.origin}/credential-groups/complete` })
161+
await checking
162+
})
163+
expect(mocks.refetch).not.toHaveBeenCalled()
164+
expect(mocks.connected).not.toHaveBeenCalled()
165+
expect(current.pending).toBe(false)
166+
})
122167

123168
it('cancels without navigating a late start response or accepting its completion', async () => {
124169
let resolve!: (value: { url: string }) => void

‎apps/sim/hooks/use-github-installation-setup.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function useGitHubInstallationSetup({
3131
onConnected,
3232
}: GitHubInstallationSetupProps) {
3333
const active = useRef<{ setupId: string; tab: Window } | null>(null)
34+
const checking = useRef<string | null>(null)
3435
const callback = useRef(onConnected)
3536
const [setupId, setSetupId] = useState<string>()
3637
const [error, setError] = useState<string | null>(null)
@@ -92,6 +93,7 @@ export function useGitHubInstallationSetup({
9293
active.current.tab.close()
9394
active.current = null
9495
setSetupId(undefined)
96+
setError(null)
9597
for (const purpose of [undefined, 'browsing'] as const) {
9698
void client.invalidateQueries({
9799
queryKey: oauthCredentialKeys.list(
@@ -124,6 +126,54 @@ export function useGitHubInstallationSetup({
124126
}
125127
}, [query.data, query.error, setupId, organizationId, client, cancelSetup])
126128

129+
const checkConnection = useCallback(
130+
async (manual = true) => {
131+
const attempt = active.current
132+
if (!attempt || !organizationId || !setupId || checking.current === attempt.setupId) return
133+
let reopened = false
134+
if (manual && attempt.tab.closed) {
135+
const tab = window.open('about:blank', '_blank', 'width=600,height=700')
136+
if (!tab) {
137+
setError('Allow pop-ups for this site to continue GitHub setup, then try again.')
138+
return
139+
}
140+
tab.opener = null
141+
attempt.tab = tab
142+
reopened = true
143+
}
144+
checking.current = attempt.setupId
145+
setError(null)
146+
try {
147+
const result = await start({ organizationId, setupId: attempt.setupId })
148+
if (active.current !== attempt) return
149+
const url = resolveGitHubSetupUrl(result.url, window.location.origin)
150+
if (new URL(url).origin === window.location.origin) {
151+
if (!attempt.tab.closed) {
152+
attempt.tab.location.href = url
153+
if (manual) attempt.tab.focus()
154+
}
155+
} else if (manual) {
156+
if (reopened) attempt.tab.location.href = url
157+
setError('Finish setup on GitHub, then check the connection again.')
158+
}
159+
await refetch()
160+
} catch (failure) {
161+
if (active.current === attempt && manual)
162+
setError(getErrorMessage(failure, 'Could not check GitHub. Try again.'))
163+
} finally {
164+
if (checking.current === attempt.setupId) checking.current = null
165+
}
166+
},
167+
[organizationId, setupId, start, refetch]
168+
)
169+
170+
useEffect(() => {
171+
if (!setupId) return
172+
const onFocus = () => void checkConnection(false)
173+
window.addEventListener('focus', onFocus)
174+
return () => window.removeEventListener('focus', onFocus)
175+
}, [setupId, checkConnection])
176+
127177
const connect = useCallback(
128178
async (intent?: StartGitHubSearchSetupBody['intent']) => {
129179
if (!organizationId) return
@@ -168,5 +218,12 @@ export function useGitHubInstallationSetup({
168218
void cancelSetup({ organizationId, setupId: attempt.setupId }).catch(() => undefined)
169219
}, [organizationId, cancelSetup])
170220

171-
return { connect, cancel, pending: isStarting || Boolean(setupId), error }
221+
return {
222+
connect,
223+
cancel,
224+
checkConnection,
225+
isChecking: isStarting,
226+
pending: isStarting || Boolean(setupId),
227+
error,
228+
}
172229
}

‎apps/sim/lib/knowledge/application/github-installations.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ vi.mock('@/lib/oauth/github-installation', () => ({
3636
GitHubInstallationError: class extends Error {
3737
constructor(
3838
message: string,
39-
readonly status?: number
39+
readonly status?: number,
40+
readonly operation?: string
4041
) {
4142
super(message)
4243
}
@@ -196,6 +197,22 @@ describe('GitHub Search installation application operations', () => {
196197
m.list.mockRejectedValueOnce(error)
197198
await expect(listGitHubSearchInstallations.execute({ principal, input })).rejects.toBe(error)
198199
})
200+
it('reports missing app membership access without requesting another OAuth connection', async () => {
201+
setupReader()
202+
m.list.mockRejectedValueOnce(
203+
new GitHubInstallationError(
204+
'Approve Organization Members read-only access',
205+
403,
206+
'membership-permissions'
207+
)
208+
)
209+
await expect(listGitHubSearchInstallations.execute({ principal, input })).rejects.toMatchObject(
210+
{
211+
code: 'validation',
212+
message: 'Approve Organization Members read-only access',
213+
}
214+
)
215+
})
199216
it('preserves transient token refresh failures instead of requesting OAuth', async () => {
200217
setupReader()
201218
const error = new ManagedOAuthCredentialError(

‎apps/sim/lib/knowledge/application/github-installations.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ export const listGitHubSearchInstallations = defineAuthorizedKnowledgeUseCase({
121121
{ signal: input.signal }
122122
)
123123
} catch (error) {
124+
if (
125+
error instanceof GitHubInstallationError &&
126+
error.operation === 'membership-permissions'
127+
)
128+
throw new OrchestrationError('validation', error.message)
124129
/** Only reader-token discovery can request reauthorization; App JWT failures stay errors. */
125130
if (
126131
(error instanceof GitHubInstallationError && error.status === 401) ||

‎apps/sim/lib/knowledge/application/github-setup.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,62 @@ describe('GitHub setup lifecycle', () => {
307307
expect(m.list).not.toHaveBeenCalled()
308308
expect(m.reader).not.toHaveBeenCalled()
309309
})
310+
it('rechecks an installation left on GitHub settings and completes the original attempt', async () => {
311+
const state = new URL((await start()).url).searchParams.get('state')!
312+
m.list.mockResolvedValue({
313+
available: true,
314+
needsUserConnection: false,
315+
installations: [installation],
316+
})
317+
expect(new URL((await start()).url).pathname).toBe('/credential-groups/complete')
318+
await expect(status()).resolves.toEqual({ status: 'completed', credential: resultCredential })
319+
expect(m.connect).toHaveBeenCalledExactlyOnceWith(
320+
expect.objectContaining({
321+
principal,
322+
input: expect.objectContaining({
323+
organizationId: input.organizationId,
324+
installationId: '42',
325+
}),
326+
})
327+
)
328+
await callback(state)
329+
expect(m.connect).toHaveBeenCalledOnce()
330+
})
331+
it('keeps incomplete installation checks pending without replacing callback state', async () => {
332+
const initial = await start()
333+
const attempt = await readGitHubSetupAttempt(scope)
334+
await expect(start()).resolves.toEqual(initial)
335+
expect(await readGitHubSetupAttempt(scope)).toEqual(attempt)
336+
expect(m.connect).not.toHaveBeenCalled()
337+
})
338+
it('lets the user choose after explicitly adding another organization without a callback', async () => {
339+
m.list.mockResolvedValue({
340+
available: true,
341+
needsUserConnection: false,
342+
installations: [installation],
343+
})
344+
const state = new URL((await start('install')).url).searchParams.get('state')!
345+
m.list.mockResolvedValue({
346+
available: true,
347+
needsUserConnection: false,
348+
installations: [installation, secondInstallation],
349+
})
350+
expect(new URL((await start()).url).pathname).toBe('/knowledge/github/setup')
351+
await expect(status()).resolves.toEqual({
352+
status: 'choosing',
353+
installations: [installation, secondInstallation],
354+
})
355+
expect(m.connect).not.toHaveBeenCalled()
356+
expect(new URL((await callback(state)).url).pathname).toBe('/knowledge/github/setup')
357+
expect(m.connect).not.toHaveBeenCalled()
358+
})
359+
it('does not replace an installation attempt when a connection check has a transient failure', async () => {
360+
await start()
361+
m.list.mockRejectedValueOnce(new Error('GitHub unavailable'))
362+
await expect(start()).rejects.toThrow('GitHub unavailable')
363+
await expect(status()).resolves.toEqual({ status: 'pending' })
364+
expect(m.connect).not.toHaveBeenCalled()
365+
})
310366
it.each([{ userId: 'other-user' }, { sessionId: 'other-browser' }])(
311367
'cannot read or consume another initiating session: %s',
312368
async (change) => {
@@ -502,6 +558,26 @@ function deferred<T>() {
502558
}
503559

504560
describe('GitHub setup atomic claims', () => {
561+
it('cancellation during an installation recheck prevents a late grant', async () => {
562+
await start()
563+
const entered = deferred<void>()
564+
const listed = deferred<{
565+
available: boolean
566+
needsUserConnection: boolean
567+
installations: (typeof installation)[]
568+
}>()
569+
m.list.mockImplementationOnce(() => {
570+
entered.resolve()
571+
return listed.promise
572+
})
573+
const pending = start()
574+
await entered.promise
575+
await cancel()
576+
listed.resolve({ available: true, needsUserConnection: false, installations: [installation] })
577+
await expect(pending).rejects.toMatchObject({ code: 'conflict' })
578+
expect(m.connect).not.toHaveBeenCalled()
579+
await expect(status()).resolves.toEqual({ status: 'expired' })
580+
})
505581
it('never wraps nested authorized operations in a global-pool transaction', async () => {
506582
m.list.mockImplementationOnce(async () => {
507583
expect(db.transaction).not.toHaveBeenCalled()

0 commit comments

Comments
 (0)