chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { exchangeCodeForAccessToken } from '../auth'
|
||||
import { verifyOAuthCallbackHmac } from './hmac'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const SHOPIFY_OAUTH_SCOPES = ['read_products', 'read_orders', 'read_customers'].join(',')
|
||||
|
||||
const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i
|
||||
|
||||
export const oauthWizardHandler = async (props: bp.HandlerProps): Promise<sdk.Response> => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startHandler })
|
||||
.addStep({ id: 'get-shop', handler: _getShopHandler })
|
||||
.addStep({ id: 'validate-shop', handler: _validateShopHandler })
|
||||
.addStep({ id: 'authorize', handler: _authorizeHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'end', handler: _endHandler })
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayButtons({
|
||||
pageTitle: 'Connect Shopify',
|
||||
htmlOrMarkdownPageContents:
|
||||
'This wizard will connect your Shopify store to Botpress. If the integration was previously connected, the existing connection will be reset.\n\nDo you want to continue?',
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, continue', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'No, cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
|
||||
const _getShopHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayInput({
|
||||
pageTitle: 'Enter Shopify Store',
|
||||
htmlOrMarkdownPageContents:
|
||||
'Enter the domain of your Shopify store. It looks like `your-store.myshopify.com` — you can find it in the Shopify admin URL.',
|
||||
input: { label: 'e.g. your-store.myshopify.com', type: 'text' },
|
||||
nextStepId: 'validate-shop',
|
||||
})
|
||||
|
||||
const _validateShopHandler: WizardHandler = async ({ client, ctx, inputValue, responses }) => {
|
||||
if (!inputValue) {
|
||||
throw new sdk.RuntimeError('Shop domain cannot be empty')
|
||||
}
|
||||
|
||||
const shopDomain = normalizeShopDomain(inputValue)
|
||||
if (!SHOP_NAME_REGEX.test(shopDomain)) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Invalid Shop Domain',
|
||||
htmlOrMarkdownPageContents: `"${inputValue}" doesn't look like a valid Shopify store domain. Please enter a domain like \`your-store.myshopify.com\`.`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Try again', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'Cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
await _patchCredentialsState(client, ctx, { shopDomain, accessToken: undefined })
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Confirm Shopify Store',
|
||||
htmlOrMarkdownPageContents: `Is <strong>${shopDomain}.myshopify.com</strong> your Shopify store?`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' },
|
||||
{ action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => {
|
||||
const { shopDomain } = await _getCredentialsState(client, ctx)
|
||||
if (!shopDomain) {
|
||||
throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard')
|
||||
}
|
||||
|
||||
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString()
|
||||
const authorizeUrl =
|
||||
`https://${shopDomain}.myshopify.com/admin/oauth/authorize` +
|
||||
`?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` +
|
||||
`&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&state=${encodeURIComponent(ctx.webhookId)}`
|
||||
|
||||
return responses.redirectToExternalUrl(authorizeUrl)
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => {
|
||||
try {
|
||||
const state = query.get('state')
|
||||
if (state !== ctx.webhookId) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
const shopParam = query.get('shop')
|
||||
if (!code || !shopParam) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.',
|
||||
})
|
||||
}
|
||||
|
||||
const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase()
|
||||
const stored = await _getCredentialsState(client, ctx)
|
||||
if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`,
|
||||
})
|
||||
}
|
||||
|
||||
const credentials = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code })
|
||||
|
||||
await _patchCredentialsState(client, ctx, {
|
||||
shopDomain: shopDomainFromCallback,
|
||||
accessToken: credentials.accessToken,
|
||||
refreshToken: credentials.refreshToken,
|
||||
accessTokenExpiresAtSeconds: credentials.accessTokenExpiresAtSeconds,
|
||||
refreshTokenExpiresAtSeconds: credentials.refreshTokenExpiresAtSeconds,
|
||||
})
|
||||
|
||||
await client.configureIntegration({ identifier: shopDomainFromCallback })
|
||||
|
||||
return responses.redirectToStep('end')
|
||||
} catch (e) {
|
||||
logger.forBot().error({ err: e }, 'Shopify OAuth callback failed')
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => responses.endWizard({ success: true })
|
||||
|
||||
export const normalizeShopDomain = (raw: string): string =>
|
||||
raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/\.myshopify\.com$/, '')
|
||||
|
||||
type CredentialsPatch = {
|
||||
shopDomain?: string
|
||||
accessToken?: string
|
||||
refreshToken?: string
|
||||
accessTokenExpiresAtSeconds?: number
|
||||
refreshTokenExpiresAtSeconds?: number
|
||||
webhookSubscriptionIds?: string[]
|
||||
}
|
||||
|
||||
// `client.patchState` has known issues — merge manually via getState/setState
|
||||
const _patchCredentialsState = async (client: bp.Client, ctx: bp.Context, patch: CredentialsPatch) => {
|
||||
const current = await _getCredentialsState(client, ctx)
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...current, ...patch },
|
||||
})
|
||||
}
|
||||
|
||||
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context): Promise<CredentialsPatch> => {
|
||||
try {
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
return (state?.payload as CredentialsPatch | undefined) ?? {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user