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
5 changes: 5 additions & 0 deletions integrations/linear/definitions/states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const states = {
.optional()
.title('Wizard Phase')
.describe('Which leg of the two-phase OAuth wizard is currently expected on /oauth callback'),
runtimeActor: z
.enum(['user', 'app'])
.optional()
.title('Runtime Actor')
.describe('Which Linear OAuth actor type was used for the runtime credentials'),
}),
},

Expand Down
2 changes: 1 addition & 1 deletion integrations/linear/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import listable from './bp_modules/listable'
import { actions, channels, events, configuration, configurations, user, states, entities } from './definitions'

export const INTEGRATION_NAME = 'linear'
export const INTEGRATION_VERSION = '2.5.2'
export const INTEGRATION_VERSION = '2.6.0'

export default new IntegrationDefinition({
name: INTEGRATION_NAME,
Expand Down
6 changes: 3 additions & 3 deletions integrations/linear/src/misc/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export class LinearOauthClient {
})
const useDesk = useDeskOAuth(environment)
const linearOauthClient = new LinearOauthClient(useDesk)
const actor: Actor = effectiveStateName === 'adminCredentials' ? 'user' : 'app'
const actor: Actor = effectiveStateName === 'adminCredentials' ? 'user' : (environment.runtimeActor ?? 'app')
const credentials = await linearOauthClient.resolveValidCredentials(effectivePayload, actor)

if (credentials.accessToken !== effectivePayload.accessToken) {
Expand Down Expand Up @@ -319,8 +319,8 @@ export const registerWebhook = async ({
logger.forBot().info('Linear webhook registered successfully.')
}

export const revokeToken = async (token: string) => {
const form = new URLSearchParams({ token, token_type_hint: 'access_token' })
export const revokeToken = async (token: string, tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token') => {
const form = new URLSearchParams({ token, token_type_hint: tokenTypeHint })
try {
await axios.post(`${linearEndpoint}/oauth/revoke`, form.toString(), { headers: oauthHeaders })
} catch (err: unknown) {
Expand Down
107 changes: 99 additions & 8 deletions integrations/linear/src/oauth-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import * as bp from '.botpress'

const REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
const APP_SCOPES = 'read,write,issues:create,comments:create'
const ADMIN_SCOPES = 'read,write,admin'
const ADMIN_SCOPES = 'read,write,admin,issues:create,comments:create'
const USER_ACTOR_FALLBACK_STEP = 'use-user-actor'

const _buildAuthorizeUrl = ({
clientId,
Expand All @@ -29,6 +30,54 @@ const _buildAuthorizeUrl = ({
`&state=${state}` +
`&scope=${scopes}`

const _saveUserActorRuntimeCredentials = async ({
ctx,
client,
responses,
setIntegrationIdentifier,
}: Pick<bp.HandlerProps, 'ctx' | 'client'> &
Pick<oauthWizard.WizardStepInputProps, 'responses' | 'setIntegrationIdentifier'>) => {
const {
state: { payload: environment },
} = await client.getState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
})
const {
state: { payload: adminCredentials },
} = await client.getState({
type: 'integration',
name: 'adminCredentials',
id: ctx.integrationId,
})

if (!adminCredentials.accessToken) {
return responses.endWizard({
success: false,
errorMessage: 'Cannot install with user actor because user OAuth credentials are missing.',
})
}

const linearClient = new LinearClient({ accessToken: adminCredentials.accessToken })
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: adminCredentials,
})
await client.setState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
payload: { ...environment, runtimeActor: 'user' },
})

const organization = await linearClient.organization
setIntegrationIdentifier(organization.id)
return responses.endWizard({ success: true })
}

const _startStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({ ctx, client, query, responses }) => {
const searchParams = new URLSearchParams(query)
const payload = {
Expand Down Expand Up @@ -60,9 +109,38 @@ const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async
responses,
setIntegrationIdentifier,
}) => {
const {
state: { payload: environment },
} = await client.getState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
})

const error = query.get('error')
if (error) {
const description = query.get('error_description') ?? ''
if (environment.wizardPhase === 'app') {
logger.forBot().warn(`Linear app-actor OAuth failed (${error}: ${description}). Asking for user-actor fallback.`)
return responses.displayButtons({
pageTitle: 'Linear app authorization failed',
htmlOrMarkdownPageContents:
'You can continue without automatic webhooks. Botpress will still be able to call Linear using your account, but Linear events will not be sent back to Botpress unless webhooks are configured by a workspace admin.',
buttons: [
{
label: 'Install without webhooks',
buttonType: 'primary',
action: 'navigate',
navigateToStep: USER_ACTOR_FALLBACK_STEP,
},
{
label: 'Cancel',
buttonType: 'secondary',
action: 'close',
},
],
})
}
return responses.endWizard({ success: false, errorMessage: `OAuth error: ${error} - ${description}` })
}

Expand All @@ -71,13 +149,6 @@ const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async
return responses.endWizard({ success: false, errorMessage: 'Authorization code not present in OAuth callback' })
}

const {
state: { payload: environment },
} = await client.getState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
})
const useDesk = useDeskOAuth(environment)
const linearOauthClient = new LinearOauthClient(useDesk)
const tokenActor = environment.wizardPhase === 'app' ? 'app' : 'user'
Expand All @@ -91,6 +162,12 @@ const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async
id: ctx.integrationId,
payload: credentials,
})
await client.setState({
type: 'integration',
name: 'environment',
id: ctx.integrationId,
payload: { ...environment, runtimeActor: 'app' },
})

const linearClient = new LinearClient({ accessToken: credentials.accessToken })
const organization = await linearClient.organization
Expand Down Expand Up @@ -133,8 +210,22 @@ const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async
)
}

const _useUserActorStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
ctx,
client,
responses,
setIntegrationIdentifier,
}) =>
_saveUserActorRuntimeCredentials({
ctx,
client,
responses,
setIntegrationIdentifier,
})

export const buildOAuthWizard = (props: bp.HandlerProps) =>
new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startStep })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackStep })
.addStep({ id: USER_ACTOR_FALLBACK_STEP, handler: _useUserActorStep })
.build()
65 changes: 59 additions & 6 deletions integrations/linear/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,68 @@ import { RuntimeError } from '@botpress/client'
import { LinearOauthClient, registerWebhook, revokeToken, unregisterWebhook } from './misc/linear'
import * as bp from '.botpress'

const INSUFFICIENT_LINEAR_ROLE_ERROR = 'Invalid role: admin required'
const WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE =
'You must be an admin on the Linear workspace to automatically register webhooks. ' +
'You may still use the integration without webhooks, but some functionality will be limited or unavailable. ' +
'In order to fully configure the integration, please connect to a Linear account on which you are an admin.'

const _isWebhookManuallyRegistered = (ctx: bp.HandlerProps['ctx']) =>
ctx.configurationType === 'apiKey' && ctx.configuration.webhookSigningSecret

const _revokeCredentials = async (credentials: { accessToken?: string; refreshToken?: string }) => {
if (credentials.accessToken) {
await revokeToken(credentials.accessToken, 'access_token')
}
if (credentials.refreshToken) {
await revokeToken(credentials.refreshToken, 'refresh_token')
}
}

const _getWebhookRegistrationErrorMessage = (errorMessage: string) => {
if (errorMessage.includes(INSUFFICIENT_LINEAR_ROLE_ERROR)) {
return WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE
}

return `Failed to register webhook: ${errorMessage}`
}

const _reportWebhookRegistrationIssue = (logger: bp.HandlerProps['logger'], errorMessage: string) => {
if (!errorMessage.includes(INSUFFICIENT_LINEAR_ROLE_ERROR)) {
return
}

logger.issue({
type: 'issue',
title: 'Linear webhook registration requires an admin',
description: WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE,
category: 'configuration',
groupBy: ['linear_webhook_admin_required'],
code: 'linear_webhook_admin_required',
data: {
details: {
raw: INSUFFICIENT_LINEAR_ROLE_ERROR,
pretty: WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE,
},
},
})
}

export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
const manuallyRegistered = _isWebhookManuallyRegistered(ctx)
logger.forBot().info('Registering Linear integration.')

if (!manuallyRegistered) {
const {
state: { payload: environment },
} = await client.getState({ type: 'integration', name: 'environment', id: ctx.integrationId })

if (environment.runtimeActor === 'user') {
logger.forBot().info('Skipping automatic Linear webhook registration: integration is using user-actor OAuth.')
logger.forBot().info(`Linear integration registered successfully (integrationId="${ctx.integrationId}").`)
return
}

const linearClient = await LinearOauthClient.createAdmin({ client, ctx })
const webhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
logger.forBot().info('Registering Linear webhook')
Expand All @@ -18,7 +72,8 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, l
logger.forBot().info('Linear webhook registered')
} catch (thrown) {
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
throw new RuntimeError(`Failed to register webhook: ${errorMessage}`)
_reportWebhookRegistrationIssue(logger, errorMessage)
throw new RuntimeError(_getWebhookRegistrationErrorMessage(errorMessage))
}
} else {
logger
Expand Down Expand Up @@ -47,11 +102,9 @@ export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ct
client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }),
client.getState({ type: 'integration', name: 'adminCredentials', id: ctx.integrationId }),
])
if (appState.payload.accessToken) {
await revokeToken(appState.payload.accessToken)
}
if (adminState.payload.accessToken) {
await revokeToken(adminState.payload.accessToken)
await _revokeCredentials(appState.payload)
if (adminState.payload.accessToken !== appState.payload.accessToken) {
await _revokeCredentials(adminState.payload)
}
logger.forBot().info('Linear integration unregistration completed.')
} catch (thrown) {
Expand Down
1 change: 0 additions & 1 deletion packages/client/rollup.dts.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export default {
plugins: [
dts({
tsconfig: './tsconfig.build.json',
respectExternal: true,
}),
],
}
7 changes: 4 additions & 3 deletions packages/cognitive/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"scripts": {
"check:type": "tsc --noEmit",
"build:type": "tsup --tsconfig tsconfig.build.json ./src/index.ts --dts-resolve --dts-only --clean",
"build:type": "rollup -c rollup.dts.config.mjs",
"build:neutral": "ts-node -T ./build.ts --neutral",
"build": "pnpm build:neutral && pnpm build:type && size-limit",
"test:e2e": "vitest run --dir ./e2e",
Expand Down Expand Up @@ -43,8 +43,9 @@
"axios": "^1.7.9",
"dotenv": "^16.4.4",
"esbuild": "^0.25.10",
"size-limit": "^11.1.6",
"tsup": "^8.0.2"
"rollup": "^4.60.4",
"rollup-plugin-dts": "^6.4.1",
"size-limit": "^11.1.6"
},
"engines": {
"node": ">=18.0.0"
Expand Down
14 changes: 14 additions & 0 deletions packages/cognitive/rollup.dts.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import dts from 'rollup-plugin-dts'

export default {
input: './src/index.ts',
external: [/node_modules/],
output: {
file: './dist/index.d.ts',
},
plugins: [
dts({
tsconfig: './tsconfig.build.json',
}),
],
}
Loading
Loading