Skip to content

Commit a84260f

Browse files
authored
improvement(mship): questions improvement (#6385)
* credentials continue * fixes
1 parent de5fcf9 commit a84260f

38 files changed

Lines changed: 2925 additions & 336 deletions

apps/desktop/src/main/handoff.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import {
88
buildRedeemScript,
99
type ConnectHandoffCallback,
1010
createAuthFlow,
11+
createConnectFlow,
1112
createHandoffManager,
1213
type HandoffCallback,
1314
type HandoffCallbacks,
15+
type HandoffManager,
1416
type HandoffManagerDeps,
1517
} from '@/main/handoff'
1618
import type { EventRecorder } from '@/main/observability'
@@ -241,6 +243,24 @@ describe('createHandoffManager', () => {
241243
expect(manager.consume(state, 'login')).toBe(false)
242244
expect(manager.consume(state, 'connect')).toBe(true)
243245
})
246+
247+
it('returns the chat attempt correlated with the accepted connect state', async () => {
248+
const deps = makeDeps()
249+
const manager = createHandoffManager(deps, makeCallbacks())
250+
await manager.beginConnect('google-email', {
251+
workspaceId: 'workspace-1',
252+
chatAttemptId: 'attempt-1',
253+
})
254+
const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get(
255+
'state'
256+
) as string
257+
258+
expect(manager.consumeConnect(state)).toEqual({
259+
workspaceId: 'workspace-1',
260+
chatAttemptId: 'attempt-1',
261+
})
262+
expect(manager.consumeConnect(state)).toBeNull()
263+
})
244264
})
245265

246266
describe('connect handoff account pinning', () => {
@@ -273,6 +293,50 @@ describe('connect handoff account pinning', () => {
273293
})
274294
})
275295

296+
describe('connect completion correlation', () => {
297+
function makeConnectManager(scope: { chatAttemptId?: string }): HandoffManager {
298+
return {
299+
begin: vi.fn(async () => true),
300+
beginConnect: vi.fn(async () => true),
301+
consume: vi.fn(() => true),
302+
consumeConnect: vi.fn(() => scope),
303+
clear: vi.fn(),
304+
}
305+
}
306+
307+
it('echoes the accepted handoff chat attempt to the renderer', () => {
308+
const notifyRenderer = vi.fn()
309+
const flow = createConnectFlow({
310+
handoff: makeConnectManager({ chatAttemptId: 'attempt-1' }),
311+
events: makeEvents(),
312+
focusMainWindow: vi.fn(),
313+
notifyRenderer,
314+
})
315+
316+
flow.handleCallback({ state: VALID_STATE })
317+
318+
expect(notifyRenderer).toHaveBeenCalledWith({ ok: true, chatAttemptId: 'attempt-1' })
319+
})
320+
321+
it('marks ordinary integrations-page completions as explicitly uncorrelated', () => {
322+
const notifyRenderer = vi.fn()
323+
const flow = createConnectFlow({
324+
handoff: makeConnectManager({}),
325+
events: makeEvents(),
326+
focusMainWindow: vi.fn(),
327+
notifyRenderer,
328+
})
329+
330+
flow.handleCallback({ state: VALID_STATE, error: 'oauth_failed' })
331+
332+
expect(notifyRenderer).toHaveBeenCalledWith({
333+
ok: false,
334+
error: 'oauth_failed',
335+
chatAttemptId: null,
336+
})
337+
})
338+
})
339+
276340
describe('createAuthFlow window failures', () => {
277341
function makeAuthDeps(ensureMainWindow: () => Promise<never>) {
278342
const events = makeEvents()

apps/desktop/src/main/handoff.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ export interface HandoffManagerDeps {
6767
export interface ConnectScope {
6868
workspaceId?: string
6969
credentialId?: string
70+
chatAttemptId?: string
7071
}
7172

7273
export interface HandoffManager {
7374
begin(): Promise<boolean>
7475
beginConnect(providerId: string, scope?: ConnectScope): Promise<boolean>
7576
consume(state: string, kind: HandoffKind): boolean
77+
consumeConnect(state: string): ConnectScope | null
7678
clear(): void
7779
}
7880

@@ -92,7 +94,12 @@ export function createHandoffManager(
9294
const now = deps.now ?? Date.now
9395
let loopbackServer: Server | null = null
9496
let loopbackTimer: NodeJS.Timeout | undefined
95-
let pending: { state: string; createdAt: number; kind: HandoffKind } | null = null
97+
let pending: {
98+
state: string
99+
createdAt: number
100+
kind: HandoffKind
101+
connectScope?: ConnectScope
102+
} | null = null
96103

97104
const stopLoopback = () => {
98105
clearTimeout(loopbackTimer)
@@ -214,10 +221,23 @@ export function createHandoffManager(
214221
pending = null
215222
}
216223

224+
const consumePending = (state: string, kind: HandoffKind): NonNullable<typeof pending> | null => {
225+
if (!pending || pending.kind !== kind) return null
226+
if (now() - pending.createdAt > HANDOFF_TTL_MS) {
227+
clear()
228+
return null
229+
}
230+
if (!safeCompare(pending.state, state)) return null
231+
const consumed = pending
232+
clear()
233+
return consumed
234+
}
235+
217236
const beginFlow = async (
218237
kind: HandoffKind,
219238
landingPath: string,
220-
params: Record<string, string>
239+
params: Record<string, string>,
240+
connectScope?: ConnectScope
221241
): Promise<boolean> => {
222242
const state = generateShortId(STATE_LENGTH)
223243
// startLoopback() already tore down any prior server; if this bind fails,
@@ -228,7 +248,12 @@ export function createHandoffManager(
228248
clear()
229249
return false
230250
}
231-
pending = { state, createdAt: now(), kind }
251+
pending = {
252+
state,
253+
createdAt: now(),
254+
kind,
255+
...(connectScope ? { connectScope: { ...connectScope } } : {}),
256+
}
232257
const landing = new URL(landingPath, deps.origin())
233258
for (const [key, value] of Object.entries(params)) {
234259
landing.searchParams.set(key, value)
@@ -259,26 +284,24 @@ export function createHandoffManager(
259284
// unknown (offline, signed out): the page then falls back to its normal
260285
// login redirect rather than blocking a connect on a failed probe.
261286
const userId = await deps.currentUserId()
262-
return beginFlow('connect', '/desktop/connect', {
263-
provider: providerId,
264-
...(userId ? { user: userId } : {}),
265-
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
266-
...(scope.credentialId ? { credentialId: scope.credentialId } : {}),
267-
})
287+
return beginFlow(
288+
'connect',
289+
'/desktop/connect',
290+
{
291+
provider: providerId,
292+
...(userId ? { user: userId } : {}),
293+
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
294+
...(scope.credentialId ? { credentialId: scope.credentialId } : {}),
295+
},
296+
scope
297+
)
268298
},
269299
consume(state: string, kind: HandoffKind) {
270-
if (!pending || pending.kind !== kind) {
271-
return false
272-
}
273-
if (now() - pending.createdAt > HANDOFF_TTL_MS) {
274-
clear()
275-
return false
276-
}
277-
if (!safeCompare(pending.state, state)) {
278-
return false
279-
}
280-
clear()
281-
return true
300+
return consumePending(state, kind) !== null
301+
},
302+
consumeConnect(state: string) {
303+
const consumed = consumePending(state, 'connect')
304+
return consumed ? { ...(consumed.connectScope ?? {}) } : null
282305
},
283306
clear,
284307
}
@@ -443,6 +466,8 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
443466
export interface ConnectHandoffResult {
444467
ok: boolean
445468
error?: string
469+
/** Exact Mothership chat attempt, or null for ordinary integration flows. */
470+
chatAttemptId: string | null
446471
}
447472

448473
export interface ConnectFlowDeps {
@@ -476,19 +501,24 @@ export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow {
476501
return opened
477502
},
478503
handleCallback(callback: ConnectHandoffCallback) {
479-
if (!deps.handoff.consume(callback.state, 'connect')) {
504+
const scope = deps.handoff.consumeConnect(callback.state)
505+
if (!scope) {
480506
deps.events.record('connect_handoff_state_fail')
481507
return
482508
}
483509
if (callback.error === undefined) {
484510
deps.events.record('connect_handoff_ok')
485511
deps.focusMainWindow()
486-
deps.notifyRenderer({ ok: true })
512+
deps.notifyRenderer({ ok: true, chatAttemptId: scope.chatAttemptId ?? null })
487513
return
488514
}
489515
deps.events.record('connect_handoff_error', { error: callback.error })
490516
deps.focusMainWindow()
491-
deps.notifyRenderer({ ok: false, error: callback.error })
517+
deps.notifyRenderer({
518+
ok: false,
519+
error: callback.error,
520+
chatAttemptId: scope.chatAttemptId ?? null,
521+
})
492522
},
493523
}
494524
}

apps/desktop/src/main/ipc.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,20 @@ describe('registerIpcHandlers', () => {
337337

338338
// Chip-initiated connects carry workspace/credential scope; malformed
339339
// scopes (wrong types, unsafe ids) are rejected before the handoff.
340-
expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1' })).toBe(
341-
true
342-
)
340+
expect(
341+
await handler?.(appEvent, 'slack', {
342+
workspaceId: 'ws1',
343+
credentialId: 'cred_1',
344+
chatAttemptId: 'attempt_1',
345+
})
346+
).toBe(true)
343347
expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {
344348
workspaceId: 'ws1',
345349
credentialId: 'cred_1',
350+
chatAttemptId: 'attempt_1',
346351
})
347352
expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false)
353+
expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false)
348354
expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false)
349355
})
350356

apps/desktop/src/main/ipc.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ function parseDesktopScope(raw: unknown): string | null {
9090
export interface OAuthConnectScope {
9191
workspaceId?: string
9292
credentialId?: string
93+
chatAttemptId?: string
9394
}
9495

9596
/**
@@ -104,7 +105,11 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi
104105
if (typeof raw !== 'object') {
105106
return undefined
106107
}
107-
const { workspaceId, credentialId } = raw as { workspaceId?: unknown; credentialId?: unknown }
108+
const { workspaceId, credentialId, chatAttemptId } = raw as {
109+
workspaceId?: unknown
110+
credentialId?: unknown
111+
chatAttemptId?: unknown
112+
}
108113
if (
109114
workspaceId !== undefined &&
110115
(typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId))
@@ -117,9 +122,16 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi
117122
) {
118123
return undefined
119124
}
125+
if (
126+
chatAttemptId !== undefined &&
127+
(typeof chatAttemptId !== 'string' || !ID_PATTERN.test(chatAttemptId))
128+
) {
129+
return undefined
130+
}
120131
return {
121132
...(workspaceId !== undefined ? { workspaceId } : {}),
122133
...(credentialId !== undefined ? { credentialId } : {}),
134+
...(chatAttemptId !== undefined ? { chatAttemptId } : {}),
123135
}
124136
}
125137

apps/sim/app/api/auth/trello/authorize/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { env } from '@/lib/core/config/env'
88
import { getBaseUrl } from '@/lib/core/utils/urls'
9+
import { isSameOrigin } from '@/lib/core/utils/validation'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
1112

@@ -14,6 +15,7 @@ const logger = createLogger('TrelloAuthorize')
1415
export const dynamic = 'force-dynamic'
1516

1617
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
18+
const TRELLO_RETURN_URL_COOKIE = 'trello_return_url'
1719
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
1820
const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
1921

@@ -26,6 +28,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2628

2729
const parsed = await parseRequest(authorizeTrelloContract, request, {})
2830
if (!parsed.success) return parsed.response
31+
const { returnUrl: requestedReturnUrl } = parsed.data.query
2932

3033
const apiKey = env.TRELLO_API_KEY
3134

@@ -57,6 +60,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5760
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
5861
path: TRELLO_STATE_COOKIE_PATH,
5962
})
63+
if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) {
64+
response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, {
65+
httpOnly: true,
66+
secure: process.env.NODE_ENV === 'production',
67+
sameSite: 'lax',
68+
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
69+
path: TRELLO_STATE_COOKIE_PATH,
70+
})
71+
} else {
72+
response.cookies.delete({
73+
name: TRELLO_RETURN_URL_COOKIE,
74+
path: TRELLO_STATE_COOKIE_PATH,
75+
})
76+
}
6077
return response
6178
} catch (error) {
6279
logger.error('Error initiating Trello authorization:', error)

0 commit comments

Comments
 (0)