From 4ef2c86241dc2a9443c8e5dc169c5e856de07499 Mon Sep 17 00:00:00 2001 From: Mak <98408710+makhlouf1102@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:27:49 -0400 Subject: [PATCH 1/3] feat(jira): add issue attachment action (#15271) Co-authored-by: Makhlouf Hennine --- integrations/jira/integration.definition.ts | 2 +- integrations/jira/readme.md | 6 ++- .../jira/src/actions/add-attachment.ts | 49 +++++++++++++++++++ integrations/jira/src/actions/index.ts | 2 + integrations/jira/src/client/index.ts | 31 ++++++++++++ integrations/jira/src/definitions/actions.ts | 14 ++++++ integrations/jira/src/misc/custom-schemas.ts | 36 ++++++++++++++ 7 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 integrations/jira/src/actions/add-attachment.ts diff --git a/integrations/jira/integration.definition.ts b/integrations/jira/integration.definition.ts index 3c211f88503..f20b00d326e 100644 --- a/integrations/jira/integration.definition.ts +++ b/integrations/jira/integration.definition.ts @@ -7,7 +7,7 @@ export default new IntegrationDefinition({ title: 'Jira', description: 'This integration allows you to work with your Jira workspace, users, projects, and workflow transitions.', - version: '0.4.0', + version: '0.5.0', readme: 'readme.md', icon: 'icon.svg', configuration, diff --git a/integrations/jira/readme.md b/integrations/jira/readme.md index 9cb149c16a5..e15b78d93ce 100644 --- a/integrations/jira/readme.md +++ b/integrations/jira/readme.md @@ -1,6 +1,6 @@ # Botpress Jira Software Integration -This integration allows you to connect your Botpress chatbot with Jira Software, a popular platform for project management and issue tracking. With this integration, you can search, create, update, and transition issues, list projects, find Jira users, and post issue comments from your chatbot. +This integration allows you to connect your Botpress chatbot with Jira Software, a popular platform for project management and issue tracking. With this integration, you can search, create, update, and transition issues, list projects, find Jira users, and post issue comments through the comments channel. This version supports the Jira OAuth setup wizard. If you are testing the OAuth build, the setup flow should offer **Connect with OAuth** and **Use an API Token**. @@ -31,12 +31,14 @@ To enable the Jira Software integration in Botpress, follow these steps: Once the integration is enabled, you can start using Jira from your Botpress chatbot. The integration offers the following actions: -- **Issues**: `searchIssues` (JQL with cursor pagination), `countIssues`, `pickIssue`, `getIssue`, `newIssue`, `newIssues` (batch up to 50), `updateIssue`, `assignIssue`, `deleteIssue`, `getIssueTransitions`, `transitionIssue` +- **Issues**: `searchIssues` (JQL with cursor pagination), `countIssues`, `pickIssue`, `getIssue`, `newIssue`, `newIssues` (batch up to 50), `updateIssue`, `assignIssue`, `deleteIssue`, `addAttachment`, `getIssueTransitions`, `transitionIssue` - **Projects**: `listProjects`, `listProjectStatuses`, `listIssueTypes` (per project) - **Users**: `findUser`, `findAllUsers` To post comments to Jira issues, send text messages through the `issueComments` channel with the target `issueKey` conversation tag. +To upload an image or file to a Jira issue, call `addAttachment` with an `issueKey`, `filename`, and either a `fileUrl` or base64-encoded `data`. + To move an issue through its workflow, first call `getIssueTransitions` for that issue to discover valid transition IDs, then pass one to `transitionIssue`. > Issue search uses Atlassian's `POST /rest/api/3/search/jql` endpoint (replacing the deprecated `/rest/api/3/search` retired in May 2025). Pagination is cursor-based — pass the `nextToken` from the previous response to fetch the next page. Use `countIssues` if you only need a total. diff --git a/integrations/jira/src/actions/add-attachment.ts b/integrations/jira/src/actions/add-attachment.ts new file mode 100644 index 00000000000..6e367aff798 --- /dev/null +++ b/integrations/jira/src/actions/add-attachment.ts @@ -0,0 +1,49 @@ +import { RuntimeError } from '@botpress/sdk' +import { addAttachmentInputSchema, addAttachmentOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +const _downloadFile = async (fileUrl: string): Promise<{ data: ArrayBuffer; contentType?: string }> => { + const response = await fetch(fileUrl) + if (!response.ok) { + throw new Error(`Failed to download file (${response.status} ${response.statusText})`) + } + + return { + data: await response.arrayBuffer(), + contentType: response.headers.get('content-type') ?? undefined, + } +} + +const _decodeBase64 = (data: string): Buffer => { + const base64 = data.includes(',') ? data.split(',').pop()! : data + return Buffer.from(base64, 'base64') +} + +export const addAttachment: Implementation['actions']['addAttachment'] = async ({ client, ctx, input, logger }) => { + const validatedInput = addAttachmentInputSchema.parse(input) + const jiraClient = await getClient({ client, ctx, logger }) + + try { + if (!validatedInput.fileUrl && !validatedInput.data) { + throw new RuntimeError('Either fileUrl or data must be provided') + } + + const file = validatedInput.fileUrl + ? await _downloadFile(validatedInput.fileUrl) + : { data: _decodeBase64(validatedInput.data!), contentType: undefined } + + const attachments = await jiraClient.addAttachmentToIssue(validatedInput.issueKey, { + filename: validatedInput.filename, + contentType: validatedInput.contentType ?? file.contentType, + data: file.data, + }) + + logger.forBot().info(`Successful - Add Attachment - ${validatedInput.issueKey} - ${validatedInput.filename}`) + return addAttachmentOutputSchema.parse({ issueKey: validatedInput.issueKey, attachments }) + } catch (error) { + logger.forBot().debug(`'Add Attachment' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to add attachment to issue ${validatedInput.issueKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/index.ts b/integrations/jira/src/actions/index.ts index 2327edbbf98..774df8c638c 100644 --- a/integrations/jira/src/actions/index.ts +++ b/integrations/jira/src/actions/index.ts @@ -1,3 +1,4 @@ +import { addAttachment } from './add-attachment' import { assignIssue } from './assign-issue' import { countIssues } from './count-issues' import { deleteIssue } from './delete-issue' @@ -32,4 +33,5 @@ export default { transitionIssue, listIssueTypes, listProjectStatuses, + addAttachment, } diff --git a/integrations/jira/src/client/index.ts b/integrations/jira/src/client/index.ts index 00d14ae2d71..3a0f6865a24 100644 --- a/integrations/jira/src/client/index.ts +++ b/integrations/jira/src/client/index.ts @@ -46,6 +46,12 @@ export type IssuePickerResponse = { }> } +export type AttachmentInput = { + filename: string + contentType?: string + data: ArrayBuffer | Uint8Array +} + export class JiraApi { private _client: Version3Client public readonly host: string @@ -191,6 +197,31 @@ export class JiraApi { return id } + public async addAttachmentToIssue( + issueIdOrKey: string, + attachment: AttachmentInput + ): Promise { + const form = new FormData() + form.append( + 'file', + new Blob([attachment.data], attachment.contentType ? { type: attachment.contentType } : undefined), + attachment.filename + ) + + return await this._client.sendRequest( + { + url: `/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/attachments`, + method: 'POST', + headers: { + Accept: 'application/json', + 'X-Atlassian-Token': 'no-check', + }, + data: form, + }, + undefined as never + ) + } + public async findUser(query: string): Promise { const users = await this._client.userSearch.findUsers({ query, diff --git a/integrations/jira/src/definitions/actions.ts b/integrations/jira/src/definitions/actions.ts index 3127c7dba32..5c6723c34c5 100644 --- a/integrations/jira/src/definitions/actions.ts +++ b/integrations/jira/src/definitions/actions.ts @@ -24,6 +24,8 @@ import { listIssueTypesOutputSchema, listProjectStatusesInputSchema, listProjectStatusesOutputSchema, + addAttachmentInputSchema, + addAttachmentOutputSchema, assignIssueInputSchema, assignIssueOutputSchema, deleteIssueInputSchema, @@ -173,6 +175,17 @@ const listProjectStatuses = { }, } satisfies SdkAction +const addAttachment = { + title: 'Add Attachment', + description: 'Upload a file or image as an attachment on a Jira issue.', + input: { + schema: addAttachmentInputSchema, + }, + output: { + schema: addAttachmentOutputSchema, + }, +} satisfies SdkAction + const assignIssue = { title: 'Assign Issue', description: @@ -222,4 +235,5 @@ export const actions = { transitionIssue, listIssueTypes, listProjectStatuses, + addAttachment, } satisfies SdkActions diff --git a/integrations/jira/src/misc/custom-schemas.ts b/integrations/jira/src/misc/custom-schemas.ts index 5f63e9892d5..4742d83698a 100644 --- a/integrations/jira/src/misc/custom-schemas.ts +++ b/integrations/jira/src/misc/custom-schemas.ts @@ -237,6 +237,42 @@ export const listProjectStatusesOutputSchema = z.object({ items: z.array(jiraStatusSchema).title('Items').describe('Statuses grouped per issue type for the project'), }) +export const jiraAttachmentSchema = z.object({ + id: z.string().title('Attachment ID').describe('Jira identifier of the uploaded attachment'), + filename: z.string().optional().title('Filename').describe('Attachment filename'), + mimeType: z.string().optional().title('MIME Type').describe('Attachment MIME type'), + size: z.number().optional().title('Size').describe('Attachment size in bytes'), + self: z.string().optional().title('Self URL').describe('Jira API URL for the attachment metadata'), + content: z.string().optional().title('Content URL').describe('Jira API URL for downloading the attachment content'), + thumbnail: z + .string() + .optional() + .title('Thumbnail URL') + .describe('Jira API URL for the attachment thumbnail, when available'), +}) + +export const addAttachmentInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the Jira issue to attach the file to'), + filename: z.string().title('Filename').describe('Name Jira should use for the uploaded attachment'), + contentType: z + .string() + .optional() + .title('Content Type') + .describe('MIME content type of the file, such as image/png or application/pdf'), + fileUrl: z + .string() + .url() + .optional() + .title('File URL') + .describe('URL to download the file from before uploading to Jira'), + data: z.string().optional().title('Data').describe('Base64-encoded file content to upload to Jira'), +}) + +export const addAttachmentOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the Jira issue that received the attachment'), + attachments: z.array(jiraAttachmentSchema).title('Attachments').describe('Attachments returned by Jira'), +}) + export const newIssuesInputSchema = z.object({ issues: z .array(newIssueInputSchema) From c82f3538581c5777cef4a9a53146537943f70a81 Mon Sep 17 00:00:00 2001 From: Tiago Justino Date: Mon, 8 Jun 2026 16:09:57 -0300 Subject: [PATCH 2/3] feat(integration): implements shopify integration (#15131) Co-authored-by: Mathieu Faucher --- .../actions/deploy-integrations/action.yml | 38 +- .../deploy-integrations-production.yml | 2 + .../workflows/deploy-integrations-staging.yml | 2 + .gitignore | 1 + .../shopify-admin/definitions/actions.ts | 102 ++++ .../shopify-admin/definitions/events.ts | 30 ++ .../shopify-admin/definitions/index.ts | 18 + .../shopify-admin/definitions/schemas.ts | 90 ++++ .../shopify-admin/definitions/states.ts | 37 ++ integrations/shopify-admin/hub.md | 36 ++ integrations/shopify-admin/icon.svg | 12 + .../shopify-admin/integration.definition.ts | 17 + integrations/shopify-admin/linkTemplate.vrl | 4 + integrations/shopify-admin/package.json | 19 + .../shopify-admin/shopify.app.production.toml | 22 + .../shopify-admin/shopify.app.staging.toml | 22 + integrations/shopify-admin/shopify.app.toml | 22 + .../shopify-admin/src/actions/get-order.ts | 59 +++ .../shopify-admin/src/actions/get-product.ts | 44 ++ .../shopify-admin/src/actions/index.ts | 14 + .../src/actions/list-customer-orders.ts | 57 ++ .../src/actions/list-products.ts | 59 +++ .../src/actions/search-customers.ts | 30 ++ integrations/shopify-admin/src/auth.test.ts | 215 ++++++++ integrations/shopify-admin/src/auth.ts | 173 +++++++ .../shopify-admin/src/client/index.test.ts | 81 +++ .../shopify-admin/src/client/index.ts | 118 +++++ .../shopify-admin/src/client/queries/admin.ts | 212 ++++++++ .../src/client/queries/common.ts | 1 + .../src/events/order-cancelled.ts | 13 + .../shopify-admin/src/events/order-created.ts | 13 + .../src/events/order-fulfilled.ts | 13 + .../shopify-admin/src/events/order-paid.ts | 13 + .../shopify-admin/src/events/order-updated.ts | 13 + .../shopify-admin/src/handler.test.ts | 97 ++++ integrations/shopify-admin/src/handler.ts | 67 +++ integrations/shopify-admin/src/index.ts | 12 + .../shopify-admin/src/oauth/hmac.test.ts | 101 ++++ integrations/shopify-admin/src/oauth/hmac.ts | 48 ++ .../shopify-admin/src/oauth/wizard.test.ts | 44 ++ .../shopify-admin/src/oauth/wizard.ts | 186 +++++++ integrations/shopify-admin/src/setup.ts | 77 +++ .../shopify-admin/src/transformers.test.ts | 243 +++++++++ .../shopify-admin/src/transformers.ts | 147 ++++++ integrations/shopify-admin/tsconfig.json | 10 + integrations/shopify-admin/vitest.config.ts | 2 + .../shopify-storefront/definitions/actions.ts | 189 +++++++ .../shopify-storefront/definitions/index.ts | 17 + .../shopify-storefront/definitions/schemas.ts | 103 ++++ .../shopify-storefront/definitions/states.ts | 20 + integrations/shopify-storefront/hub.md | 34 ++ integrations/shopify-storefront/icon.svg | 9 + .../integration.definition.ts | 16 + .../shopify-storefront/linkTemplate.vrl | 4 + integrations/shopify-storefront/package.json | 19 + .../shopify.app.production.toml | 22 + .../shopify.app.staging.toml | 22 + .../shopify-storefront/shopify.app.toml | 22 + .../src/actions/add-cart-lines.ts | 35 ++ .../src/actions/apply-cart-discount.ts | 36 ++ .../src/actions/create-cart.ts | 51 ++ .../src/actions/get-cart.ts | 21 + .../src/actions/get-collection.ts | 76 +++ .../src/actions/get-product.ts | 52 ++ .../shopify-storefront/src/actions/index.ts | 20 + .../src/actions/list-collections.ts | 39 ++ .../src/actions/search-products.ts | 58 +++ .../src/client/index.test.ts | 49 ++ .../shopify-storefront/src/client/index.ts | 93 ++++ .../src/client/queries/admin.ts | 34 ++ .../src/client/queries/common.ts | 1 + .../src/client/queries/storefront.ts | 209 ++++++++ .../src/client/storefront.ts | 67 +++ .../shopify-storefront/src/handler.test.ts | 68 +++ .../shopify-storefront/src/handler.ts | 45 ++ integrations/shopify-storefront/src/index.ts | 12 + .../shopify-storefront/src/oauth/hmac.test.ts | 101 ++++ .../shopify-storefront/src/oauth/hmac.ts | 48 ++ .../src/oauth/wizard.test.ts | 44 ++ .../shopify-storefront/src/oauth/wizard.ts | 235 +++++++++ integrations/shopify-storefront/src/setup.ts | 12 + .../src/transformers.test.ts | 195 +++++++ .../shopify-storefront/src/transformers.ts | 119 +++++ integrations/shopify-storefront/tsconfig.json | 10 + .../shopify-storefront/vitest.config.ts | 2 + pnpm-lock.yaml | 487 ++++++++++++++++++ 86 files changed, 5328 insertions(+), 4 deletions(-) create mode 100644 integrations/shopify-admin/definitions/actions.ts create mode 100644 integrations/shopify-admin/definitions/events.ts create mode 100644 integrations/shopify-admin/definitions/index.ts create mode 100644 integrations/shopify-admin/definitions/schemas.ts create mode 100644 integrations/shopify-admin/definitions/states.ts create mode 100644 integrations/shopify-admin/hub.md create mode 100644 integrations/shopify-admin/icon.svg create mode 100644 integrations/shopify-admin/integration.definition.ts create mode 100644 integrations/shopify-admin/linkTemplate.vrl create mode 100644 integrations/shopify-admin/package.json create mode 100644 integrations/shopify-admin/shopify.app.production.toml create mode 100644 integrations/shopify-admin/shopify.app.staging.toml create mode 100644 integrations/shopify-admin/shopify.app.toml create mode 100644 integrations/shopify-admin/src/actions/get-order.ts create mode 100644 integrations/shopify-admin/src/actions/get-product.ts create mode 100644 integrations/shopify-admin/src/actions/index.ts create mode 100644 integrations/shopify-admin/src/actions/list-customer-orders.ts create mode 100644 integrations/shopify-admin/src/actions/list-products.ts create mode 100644 integrations/shopify-admin/src/actions/search-customers.ts create mode 100644 integrations/shopify-admin/src/auth.test.ts create mode 100644 integrations/shopify-admin/src/auth.ts create mode 100644 integrations/shopify-admin/src/client/index.test.ts create mode 100644 integrations/shopify-admin/src/client/index.ts create mode 100644 integrations/shopify-admin/src/client/queries/admin.ts create mode 100644 integrations/shopify-admin/src/client/queries/common.ts create mode 100644 integrations/shopify-admin/src/events/order-cancelled.ts create mode 100644 integrations/shopify-admin/src/events/order-created.ts create mode 100644 integrations/shopify-admin/src/events/order-fulfilled.ts create mode 100644 integrations/shopify-admin/src/events/order-paid.ts create mode 100644 integrations/shopify-admin/src/events/order-updated.ts create mode 100644 integrations/shopify-admin/src/handler.test.ts create mode 100644 integrations/shopify-admin/src/handler.ts create mode 100644 integrations/shopify-admin/src/index.ts create mode 100644 integrations/shopify-admin/src/oauth/hmac.test.ts create mode 100644 integrations/shopify-admin/src/oauth/hmac.ts create mode 100644 integrations/shopify-admin/src/oauth/wizard.test.ts create mode 100644 integrations/shopify-admin/src/oauth/wizard.ts create mode 100644 integrations/shopify-admin/src/setup.ts create mode 100644 integrations/shopify-admin/src/transformers.test.ts create mode 100644 integrations/shopify-admin/src/transformers.ts create mode 100644 integrations/shopify-admin/tsconfig.json create mode 100644 integrations/shopify-admin/vitest.config.ts create mode 100644 integrations/shopify-storefront/definitions/actions.ts create mode 100644 integrations/shopify-storefront/definitions/index.ts create mode 100644 integrations/shopify-storefront/definitions/schemas.ts create mode 100644 integrations/shopify-storefront/definitions/states.ts create mode 100644 integrations/shopify-storefront/hub.md create mode 100644 integrations/shopify-storefront/icon.svg create mode 100644 integrations/shopify-storefront/integration.definition.ts create mode 100644 integrations/shopify-storefront/linkTemplate.vrl create mode 100644 integrations/shopify-storefront/package.json create mode 100644 integrations/shopify-storefront/shopify.app.production.toml create mode 100644 integrations/shopify-storefront/shopify.app.staging.toml create mode 100644 integrations/shopify-storefront/shopify.app.toml create mode 100644 integrations/shopify-storefront/src/actions/add-cart-lines.ts create mode 100644 integrations/shopify-storefront/src/actions/apply-cart-discount.ts create mode 100644 integrations/shopify-storefront/src/actions/create-cart.ts create mode 100644 integrations/shopify-storefront/src/actions/get-cart.ts create mode 100644 integrations/shopify-storefront/src/actions/get-collection.ts create mode 100644 integrations/shopify-storefront/src/actions/get-product.ts create mode 100644 integrations/shopify-storefront/src/actions/index.ts create mode 100644 integrations/shopify-storefront/src/actions/list-collections.ts create mode 100644 integrations/shopify-storefront/src/actions/search-products.ts create mode 100644 integrations/shopify-storefront/src/client/index.test.ts create mode 100644 integrations/shopify-storefront/src/client/index.ts create mode 100644 integrations/shopify-storefront/src/client/queries/admin.ts create mode 100644 integrations/shopify-storefront/src/client/queries/common.ts create mode 100644 integrations/shopify-storefront/src/client/queries/storefront.ts create mode 100644 integrations/shopify-storefront/src/client/storefront.ts create mode 100644 integrations/shopify-storefront/src/handler.test.ts create mode 100644 integrations/shopify-storefront/src/handler.ts create mode 100644 integrations/shopify-storefront/src/index.ts create mode 100644 integrations/shopify-storefront/src/oauth/hmac.test.ts create mode 100644 integrations/shopify-storefront/src/oauth/hmac.ts create mode 100644 integrations/shopify-storefront/src/oauth/wizard.test.ts create mode 100644 integrations/shopify-storefront/src/oauth/wizard.ts create mode 100644 integrations/shopify-storefront/src/setup.ts create mode 100644 integrations/shopify-storefront/src/transformers.test.ts create mode 100644 integrations/shopify-storefront/src/transformers.ts create mode 100644 integrations/shopify-storefront/tsconfig.json create mode 100644 integrations/shopify-storefront/vitest.config.ts diff --git a/.github/actions/deploy-integrations/action.yml b/.github/actions/deploy-integrations/action.yml index 3db7e133f67..c92f7e668a6 100644 --- a/.github/actions/deploy-integrations/action.yml +++ b/.github/actions/deploy-integrations/action.yml @@ -33,6 +33,14 @@ inputs: cloud_ops_workspace_id: description: 'Cloud Ops workspace id' required: true + shopify_admin_automation_token: + description: 'Shopify app automation token for the Shopify Admin app (app-scoped, env-specific)' + required: false + default: '' + shopify_storefront_automation_token: + description: 'Shopify app automation token for the Shopify Storefront app (app-scoped, env-specific)' + required: false + default: '' runs: using: 'composite' @@ -71,6 +79,9 @@ runs: INPUT_EXTRA_FILTER: ${{ inputs.extra_filter }} TOKEN_CLOUD_OPS_ACCOUNT: ${{ inputs.token_cloud_ops_account }} CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }} + SHOPIFY_ADMIN_AUTOMATION_TOKEN: ${{ inputs.shopify_admin_automation_token }} + SHOPIFY_STOREFRONT_AUTOMATION_TOKEN: ${{ inputs.shopify_storefront_automation_token }} + COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} SENTRY_RELEASE: ${{ github.sha }} SENTRY_ENVIRONMENT: ${{ inputs.environment }} shell: bash @@ -112,25 +123,44 @@ runs: base_command="bp deploy -v -y --noBuild --visibility public --allowDeprecated $dryrun" - upload_sandbox_scripts=false + integration_deployed=false if [ "$exists" -eq 0 ]; then echo -e "\nDeploying integration: ### $integration ###\n" pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command" - upload_sandbox_scripts=true + integration_deployed=true elif [ "$redeploy" -eq 1 ]; then echo -e "\nRe-deploying integration: ### $integration ###\n" pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command" - upload_sandbox_scripts=true + integration_deployed=true else echo -e "\nSkipping integration: ### $integration ###\n" fi # upload sandbox scripts integration_implements_sandbox=$(./.github/scripts/integration-implements-sandbox.sh "$integration") - if [ "$integration_implements_sandbox" = "true" ] && [ "$upload_sandbox_scripts" = "true" ] && [ "$is_dry_run" -eq 0 ]; then + if [ "$integration_implements_sandbox" = "true" ] && [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ]; then echo -e "\nUploading integration sandbox scripts\n" base_upload_command="uploadSandboxScripts --apiUrl=$api_url --workspaceId=$CLOUD_OPS_WORKSPACE_ID --token=$TOKEN_CLOUD_OPS_ACCOUNT --userEmail=cloud-ops@botpress.com" # shellcheck disable=SC2086 # base_upload_command contains multiple space-separated arguments pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" run -- $base_upload_command fi + + # deploy shopify app manifest (config-as-code) for the current environment + manifest_file="$integration_path/shopify.app.$INPUT_ENVIRONMENT.toml" + if [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ] && [ -f "$manifest_file" ]; then + case "$integration" in + shopify-admin) shopify_token="$SHOPIFY_ADMIN_AUTOMATION_TOKEN" ;; + shopify-storefront) shopify_token="$SHOPIFY_STOREFRONT_AUTOMATION_TOKEN" ;; + *) shopify_token="" ;; + esac + + if [ -z "$shopify_token" ]; then + echo "::warning::Found $manifest_file but no Shopify automation token for $integration - skipping manifest deploy" + else + echo -e "\nDeploying Shopify app manifest ($INPUT_ENVIRONMENT): ### $integration ###\n" + shopify_deploy_command="shopify app deploy --config $INPUT_ENVIRONMENT --allow-updates --source-control-url $COMMIT_URL" + SHOPIFY_APP_AUTOMATION_TOKEN="$shopify_token" \ + pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$shopify_deploy_command" + fi + fi done diff --git a/.github/workflows/deploy-integrations-production.yml b/.github/workflows/deploy-integrations-production.yml index 71b885a51e0..0d048fd76c0 100644 --- a/.github/workflows/deploy-integrations-production.yml +++ b/.github/workflows/deploy-integrations-production.yml @@ -36,6 +36,8 @@ jobs: sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }} cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }} + shopify_admin_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_ADMIN_AUTOMATION_TOKEN }} + shopify_storefront_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }} - name: Deploy Plugins uses: ./.github/actions/deploy-plugins with: diff --git a/.github/workflows/deploy-integrations-staging.yml b/.github/workflows/deploy-integrations-staging.yml index 6b1c5f241c3..6bf7ac081db 100644 --- a/.github/workflows/deploy-integrations-staging.yml +++ b/.github/workflows/deploy-integrations-staging.yml @@ -45,6 +45,8 @@ jobs: sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }} cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }} + shopify_admin_automation_token: ${{ secrets.STAGING_SHOPIFY_ADMIN_AUTOMATION_TOKEN }} + shopify_storefront_automation_token: ${{ secrets.STAGING_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }} - name: Deploy Plugins uses: ./.github/actions/deploy-plugins if: ${{ github.event_name != 'pull_request' }} diff --git a/.gitignore b/.gitignore index e0bc892d767..a92b9e4999a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __snapshots__ .botpress .botpresshome .botpresshome.* +.shopify .turbo .genenv/ .genenv.* diff --git a/integrations/shopify-admin/definitions/actions.ts b/integrations/shopify-admin/definitions/actions.ts new file mode 100644 index 00000000000..75a711b2af9 --- /dev/null +++ b/integrations/shopify-admin/definitions/actions.ts @@ -0,0 +1,102 @@ +import { z, IntegrationDefinitionProps } from '@botpress/sdk' +import { productSchema, customerSchema, orderSchema, pageInfoSchema } from './schemas' + +export const actions = { + listProducts: { + title: 'List Products', + description: 'Search and list products from the Shopify Admin API', + input: { + schema: z.object({ + query: z.string().optional().title('Search Query').describe('Search query to filter products'), + first: z + .number() + .min(1) + .max(250) + .default(50) + .optional() + .title('Limit') + .describe('Number of products to return'), + after: z.string().optional().title('After Cursor').describe('Cursor for pagination'), + }), + }, + output: { + schema: z.object({ + products: z.array(productSchema).title('Products').describe('List of products'), + pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'), + }), + }, + }, + + getProduct: { + title: 'Get Product', + description: 'Get a single product with its variants from the Shopify Admin API', + input: { + schema: z.object({ + productId: z + .string() + .title('Product ID') + .describe('The Shopify GID of the product (e.g. gid://shopify/Product/12345)'), + }), + }, + output: { + schema: z.object({ + product: productSchema.title('Product').describe('The product details'), + }), + }, + }, + + searchCustomers: { + title: 'Search Customers', + description: 'Search for customers by email, name, or phone in the Shopify Admin API', + input: { + schema: z.object({ + query: z.string().title('Search Query').describe('Search query (email, name, or phone)'), + }), + }, + output: { + schema: z.object({ + customers: z.array(customerSchema).title('Customers').describe('List of matching customers'), + }), + }, + }, + + getOrder: { + title: 'Get Order', + description: 'Get full order details by ID from the Shopify Admin API', + input: { + schema: z.object({ + orderId: z.string().title('Order ID').describe('The Shopify GID of the order (e.g. gid://shopify/Order/12345)'), + }), + }, + output: { + schema: z.object({ + order: orderSchema.title('Order').describe('The order details'), + }), + }, + }, + + listCustomerOrders: { + title: 'List Customer Orders', + description: 'List orders for a specific customer, optionally filtered by status', + input: { + schema: z.object({ + customerId: z + .string() + .title('Customer ID') + .describe('The Shopify GID of the customer (e.g. gid://shopify/Customer/12345)'), + status: z + .enum(['open', 'closed', 'cancelled', 'any']) + .default('any') + .optional() + .title('Status Filter') + .describe('Filter orders by status'), + first: z.number().min(1).max(250).default(50).optional().title('Limit').describe('Number of orders to return'), + }), + }, + output: { + schema: z.object({ + orders: z.array(orderSchema).title('Orders').describe('List of customer orders'), + }), + }, + }, +} satisfies IntegrationDefinitionProps['actions'] diff --git a/integrations/shopify-admin/definitions/events.ts b/integrations/shopify-admin/definitions/events.ts new file mode 100644 index 00000000000..6fce13ea68d --- /dev/null +++ b/integrations/shopify-admin/definitions/events.ts @@ -0,0 +1,30 @@ +import { IntegrationDefinitionProps } from '@botpress/sdk' +import { orderEventSchema } from './schemas' + +export const events = { + orderCreated: { + title: 'Order Created', + description: 'Triggered when a new order is placed', + schema: orderEventSchema, + }, + orderUpdated: { + title: 'Order Updated', + description: 'Triggered when an order is modified', + schema: orderEventSchema, + }, + orderCancelled: { + title: 'Order Cancelled', + description: 'Triggered when an order is cancelled', + schema: orderEventSchema, + }, + orderFulfilled: { + title: 'Order Fulfilled', + description: 'Triggered when all items in an order are fulfilled', + schema: orderEventSchema, + }, + orderPaid: { + title: 'Order Paid', + description: 'Triggered when payment for an order is confirmed', + schema: orderEventSchema, + }, +} satisfies IntegrationDefinitionProps['events'] diff --git a/integrations/shopify-admin/definitions/index.ts b/integrations/shopify-admin/definitions/index.ts new file mode 100644 index 00000000000..83843647f31 --- /dev/null +++ b/integrations/shopify-admin/definitions/index.ts @@ -0,0 +1,18 @@ +import { z } from '@botpress/sdk' + +export { actions } from './actions' +export { events } from './events' +export { states } from './states' +export * as schemas from './schemas' + +export const configuration = { + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + schema: z.object({}), +} + +export const secrets = { + SHOPIFY_CLIENT_ID: { description: 'The Client ID of the Shopify app' }, + SHOPIFY_CLIENT_SECRET: { description: 'The Client Secret of the Shopify app' }, +} diff --git a/integrations/shopify-admin/definitions/schemas.ts b/integrations/shopify-admin/definitions/schemas.ts new file mode 100644 index 00000000000..b97dc92e547 --- /dev/null +++ b/integrations/shopify-admin/definitions/schemas.ts @@ -0,0 +1,90 @@ +import { z } from '@botpress/sdk' + +export const productVariantSchema = z.object({ + id: z.string().title('Variant ID').describe('The Shopify GID of the product variant'), + title: z.string().title('Title').describe('The title of the variant'), + price: z.string().title('Price').describe('The price of the variant'), + sku: z.string().optional().title('SKU').describe('The SKU of the variant'), + inventoryQuantity: z.number().optional().title('Inventory Quantity').describe('The available inventory'), +}) + +export const productSchema = z.object({ + id: z.string().title('Product ID').describe('The Shopify GID of the product'), + title: z.string().title('Title').describe('The title of the product'), + handle: z.string().title('Handle').describe('The URL-friendly handle of the product'), + status: z.string().title('Status').describe('The status of the product (ACTIVE, ARCHIVED, DRAFT)'), + vendor: z.string().optional().title('Vendor').describe('The vendor of the product'), + productType: z.string().optional().title('Product Type').describe('The product type'), + descriptionHtml: z.string().optional().title('Description HTML').describe('The HTML description'), + createdAt: z.string().title('Created At').describe('When the product was created'), + updatedAt: z.string().title('Updated At').describe('When the product was last updated'), + storefrontUrl: z + .string() + .title('Storefront URL') + .describe("Canonical storefront URL on the shop's myshopify.com domain — always populated"), + onlineStoreUrl: z + .string() + .optional() + .title('Online Store URL') + .describe( + 'Published Online Store URL (may use a custom domain). Undefined if the product is not published to the Online Store sales channel' + ), + onlineStorePreviewUrl: z + .string() + .optional() + .title('Online Store Preview URL') + .describe('Preview URL that works even when the product is not published'), + variants: z.array(productVariantSchema).title('Variants').describe('The product variants'), +}) + +export const customerSchema = z.object({ + id: z.string().title('Customer ID').describe('The Shopify GID of the customer'), + firstName: z.string().optional().title('First Name').describe('The first name of the customer'), + lastName: z.string().optional().title('Last Name').describe('The last name of the customer'), + email: z.string().optional().title('Email').describe('The email address of the customer'), + phone: z.string().optional().title('Phone').describe('The phone number of the customer'), + numberOfOrders: z.number().optional().title('Number of Orders').describe('Total number of orders'), + amountSpent: z.string().optional().title('Amount Spent').describe('Total amount spent'), + createdAt: z.string().title('Created At').describe('When the customer was created'), + updatedAt: z.string().title('Updated At').describe('When the customer was last updated'), +}) + +export const orderLineItemSchema = z.object({ + title: z.string().title('Title').describe('The title of the line item'), + quantity: z.number().title('Quantity').describe('The quantity ordered'), + variant: productVariantSchema.optional().title('Variant').describe('The associated product variant'), +}) + +export const orderSchema = z.object({ + id: z.string().title('Order ID').describe('The Shopify GID of the order'), + name: z.string().title('Order Number').describe('The order number (e.g. #1001)'), + email: z.string().optional().title('Email').describe('The email associated with the order'), + phone: z.string().optional().title('Phone').describe('The phone number associated with the order'), + createdAt: z.string().title('Created At').describe('When the order was created'), + updatedAt: z.string().title('Updated At').describe('When the order was last updated'), + cancelledAt: z.string().optional().title('Cancelled At').describe('When the order was cancelled'), + closedAt: z.string().optional().title('Closed At').describe('When the order was closed'), + financialStatus: z.string().title('Financial Status').describe('The financial status of the order'), + fulfillmentStatus: z.string().optional().title('Fulfillment Status').describe('The fulfillment status'), + totalPrice: z.string().title('Total Price').describe('The total price of the order'), + currencyCode: z.string().title('Currency Code').describe('The currency code (e.g. USD)'), + lineItems: z.array(orderLineItemSchema).title('Line Items').describe('The order line items'), + customer: customerSchema.optional().title('Customer').describe('The customer who placed the order'), +}) + +export const orderEventSchema = z.object({ + id: z.string().title('Order ID').describe('The Shopify order ID'), + name: z.string().title('Order Number').describe('The order number (e.g. #1001)'), + email: z.string().optional().title('Email').describe('The email associated with the order'), + financialStatus: z.string().title('Financial Status').describe('The financial status'), + fulfillmentStatus: z.string().optional().title('Fulfillment Status').describe('The fulfillment status'), + totalPrice: z.string().title('Total Price').describe('The total price of the order'), + currencyCode: z.string().title('Currency Code').describe('The currency code'), + createdAt: z.string().title('Created At').describe('When the order was created'), + updatedAt: z.string().title('Updated At').describe('When the order was last updated'), +}) + +export const pageInfoSchema = z.object({ + hasNextPage: z.boolean().title('Has Next Page').describe('Whether there are more results'), + endCursor: z.string().optional().title('End Cursor').describe('Cursor for the next page'), +}) diff --git a/integrations/shopify-admin/definitions/states.ts b/integrations/shopify-admin/definitions/states.ts new file mode 100644 index 00000000000..46403daba68 --- /dev/null +++ b/integrations/shopify-admin/definitions/states.ts @@ -0,0 +1,37 @@ +import { z, IntegrationDefinitionProps } from '@botpress/sdk' + +export const states = { + credentials: { + type: 'integration', + schema: z.object({ + shopDomain: z.string().optional().title('Shop Domain').describe('The myshopify.com domain of the store'), + accessToken: z + .string() + .optional() + .title('Admin Access Token') + .describe('Shopify Admin API expiring offline access token (60-min TTL)'), + refreshToken: z + .string() + .optional() + .title('Admin Refresh Token') + .describe( + 'Used to obtain a new access token without merchant interaction. Expires after 90 days; merchant must re-authorize when this expires.' + ), + accessTokenExpiresAtSeconds: z + .number() + .optional() + .title('Access Token Expires At (s)') + .describe('Unix epoch seconds at which the access token expires.'), + refreshTokenExpiresAtSeconds: z + .number() + .optional() + .title('Refresh Token Expires At (s)') + .describe('Unix epoch seconds at which the refresh token expires.'), + webhookSubscriptionIds: z + .array(z.string()) + .optional() + .title('Webhook Subscription IDs') + .describe('GIDs of webhook subscriptions created during register; used by unregister for cleanup'), + }), + }, +} satisfies IntegrationDefinitionProps['states'] diff --git a/integrations/shopify-admin/hub.md b/integrations/shopify-admin/hub.md new file mode 100644 index 00000000000..79a3c443e39 --- /dev/null +++ b/integrations/shopify-admin/hub.md @@ -0,0 +1,36 @@ +Connect your Botpress chatbot with the Shopify Admin API to give your bot back-office access to your store: browse products, look up orders, and search customers. Order webhooks fire in real time so your bot can react to new, updated, cancelled, fulfilled, and paid orders. + +For the public-facing shopping experience (product browsing, collections, cart/checkout) use the separate **Shopify Storefront** integration. + +## Setup + +1. Install the Shopify Admin integration in your bot. +2. Enter your Shopify store domain (e.g. `my-store.myshopify.com`) when prompted. +3. Click **Authorize** to connect via OAuth. You will be redirected to Shopify to grant permissions. + +Once authorized, the integration registers webhooks for order events. No additional configuration is required. + +## Actions + +These actions use the Shopify Admin API to access back-office data such as internal product details, customer records, and order history. + +- **List Products** — Search and list products with optional query filtering and cursor-based pagination. +- **Get Product** — Retrieve a single product and its variants by Shopify GID (e.g. `gid://shopify/Product/12345`). +- **Search Customers** — Search for customers by email, name, or phone number. +- **Get Order** — Retrieve full order details including line items and customer information by order GID. +- **List Customer Orders** — List orders for a specific customer, optionally filtered by status (`open`, `closed`, `cancelled`, or `any`). + +## Events + +The integration automatically listens for Shopify order webhooks. Your bot can respond to the following events: + +- **Order Created** — Triggered when a new order is placed. +- **Order Updated** — Triggered when an order is modified. +- **Order Cancelled** — Triggered when an order is cancelled. +- **Order Fulfilled** — Triggered when all items in an order are fulfilled. +- **Order Paid** — Triggered when payment for an order is confirmed. + +## Limitations + +- Only order-related webhook events are currently supported. Product, customer, and inventory webhooks are not available in this version. +- Pagination uses cursor-based navigation. To retrieve the next page of results, pass the `after` cursor from the previous response's `pageInfo`. diff --git a/integrations/shopify-admin/icon.svg b/integrations/shopify-admin/icon.svg new file mode 100644 index 00000000000..6a2096a803d --- /dev/null +++ b/integrations/shopify-admin/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/integrations/shopify-admin/integration.definition.ts b/integrations/shopify-admin/integration.definition.ts new file mode 100644 index 00000000000..a95aafb2b12 --- /dev/null +++ b/integrations/shopify-admin/integration.definition.ts @@ -0,0 +1,17 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { actions, events, states, configuration, secrets } from './definitions' + +export default new IntegrationDefinition({ + name: 'shopify-admin', + version: '0.1.2', + title: 'Shopify Admin', + description: + 'Connect your Shopify store via the Admin GraphQL API to manage products, customers, and orders via OAuth 2.0.', + icon: 'icon.svg', + readme: 'hub.md', + configuration, + actions, + events, + states, + secrets, +}) diff --git a/integrations/shopify-admin/linkTemplate.vrl b/integrations/shopify-admin/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/shopify-admin/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/shopify-admin/package.json b/integrations/shopify-admin/package.json new file mode 100644 index 00000000000..ce6b3edf2df --- /dev/null +++ b/integrations/shopify-admin/package.json @@ -0,0 +1,19 @@ +{ + "name": "@botpresshub/shopify-admin", + "description": "Shopify Admin integration for Botpress", + "scripts": { + "build": "bp add -y && bp build", + "check:bplint": "bp lint", + "check:type": "tsc --noEmit", + "test": "vitest --run" + }, + "private": true, + "dependencies": { + "@botpress/common": "workspace:*", + "@botpress/sdk": "workspace:*" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@shopify/cli": "~4.1.0" + } +} diff --git a/integrations/shopify-admin/shopify.app.production.toml b/integrations/shopify-admin/shopify.app.production.toml new file mode 100644 index 00000000000..83e88d4c902 --- /dev/null +++ b/integrations/shopify-admin/shopify.app.production.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "87f9bef36e5c65232d4b3dc16d788792" +name = "Botpress Admin Connector" +application_url = "https://webhook.botpress.cloud/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "read_customers,read_orders,read_products" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.cloud/v1/interation/shopify-admin" diff --git a/integrations/shopify-admin/shopify.app.staging.toml b/integrations/shopify-admin/shopify.app.staging.toml new file mode 100644 index 00000000000..a0d05d7d06d --- /dev/null +++ b/integrations/shopify-admin/shopify.app.staging.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "5dc1bbd9b4da1425005409a89e959df6" +name = "Admin Integration Staging" +application_url = "https://webhook.botpress.dev/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "read_customers,read_orders,read_products" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.dev/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.dev/v1/interation/shopify-admin" diff --git a/integrations/shopify-admin/shopify.app.toml b/integrations/shopify-admin/shopify.app.toml new file mode 100644 index 00000000000..83e88d4c902 --- /dev/null +++ b/integrations/shopify-admin/shopify.app.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "87f9bef36e5c65232d4b3dc16d788792" +name = "Botpress Admin Connector" +application_url = "https://webhook.botpress.cloud/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "read_customers,read_orders,read_products" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.cloud/v1/interation/shopify-admin" diff --git a/integrations/shopify-admin/src/actions/get-order.ts b/integrations/shopify-admin/src/actions/get-order.ts new file mode 100644 index 00000000000..aaab683ac11 --- /dev/null +++ b/integrations/shopify-admin/src/actions/get-order.ts @@ -0,0 +1,59 @@ +import { RuntimeError } from '@botpress/sdk' +import { ShopifyClient } from '../client' +import { ORDER_QUERY } from '../client/queries/admin' +import { transformOrder } from '../transformers' +import * as bp from '.botpress' + +type VariantShape = { + id: string + title: string + price: string + sku: string | null + inventoryQuantity: number | null +} + +type OrderQueryResponse = { + order: { + id: string + name: string + email: string | null + phone: string | null + createdAt: string + updatedAt: string + cancelledAt: string | null + closedAt: string | null + displayFinancialStatus: string | null + displayFulfillmentStatus: string | null + totalPriceSet: { shopMoney: { amount: string; currencyCode: string } } + lineItems: { + edges: Array<{ + node: { + title: string + quantity: number + variant: VariantShape | null + } + }> + } + customer: { + id: string + firstName: string | null + lastName: string | null + email: string | null + phone: string | null + createdAt: string + updatedAt: string + } | null + } | null +} + +export const getOrder: bp.IntegrationProps['actions']['getOrder'] = async ({ input, client, ctx }) => { + const shopify = await ShopifyClient.create({ client, ctx }) + + const data = await shopify.query(ORDER_QUERY, { id: input.orderId }) + + if (!data.order) { + throw new RuntimeError(`Order not found: ${input.orderId}`) + } + + return { order: transformOrder(data.order) } +} diff --git a/integrations/shopify-admin/src/actions/get-product.ts b/integrations/shopify-admin/src/actions/get-product.ts new file mode 100644 index 00000000000..60b7b72e5a3 --- /dev/null +++ b/integrations/shopify-admin/src/actions/get-product.ts @@ -0,0 +1,44 @@ +import { RuntimeError } from '@botpress/sdk' +import { ShopifyClient } from '../client' +import { PRODUCT_QUERY } from '../client/queries/admin' +import { transformProduct } from '../transformers' +import * as bp from '.botpress' + +type ProductQueryResponse = { + product: { + id: string + title: string + handle: string + status: string + vendor: string | null + productType: string | null + descriptionHtml: string | null + createdAt: string + updatedAt: string + onlineStoreUrl: string | null + onlineStorePreviewUrl: string | null + variants: { + edges: Array<{ + node: { + id: string + title: string + price: string + sku: string | null + inventoryQuantity: number | null + } + }> + } + } | null +} + +export const getProduct: bp.IntegrationProps['actions']['getProduct'] = async ({ input, client, ctx }) => { + const shopify = await ShopifyClient.create({ client, ctx }) + + const data = await shopify.query(PRODUCT_QUERY, { id: input.productId }) + + if (!data.product) { + throw new RuntimeError(`Product not found: ${input.productId}`) + } + + return { product: transformProduct(data.product, shopify.shopDomain) } +} diff --git a/integrations/shopify-admin/src/actions/index.ts b/integrations/shopify-admin/src/actions/index.ts new file mode 100644 index 00000000000..8ac84682e95 --- /dev/null +++ b/integrations/shopify-admin/src/actions/index.ts @@ -0,0 +1,14 @@ +import { getOrder } from './get-order' +import { getProduct } from './get-product' +import { listCustomerOrders } from './list-customer-orders' +import { listProducts } from './list-products' +import { searchCustomers } from './search-customers' +import * as bp from '.botpress' + +export default { + listProducts, + getProduct, + searchCustomers, + getOrder, + listCustomerOrders, +} satisfies bp.IntegrationProps['actions'] diff --git a/integrations/shopify-admin/src/actions/list-customer-orders.ts b/integrations/shopify-admin/src/actions/list-customer-orders.ts new file mode 100644 index 00000000000..459bc316edc --- /dev/null +++ b/integrations/shopify-admin/src/actions/list-customer-orders.ts @@ -0,0 +1,57 @@ +import { RuntimeError } from '@botpress/sdk' +import { ShopifyClient } from '../client' +import { CUSTOMER_ORDERS_QUERY } from '../client/queries/admin' +import { transformOrder } from '../transformers' +import * as bp from '.botpress' + +type CustomerOrdersQueryResponse = { + customer: { + orders: { + edges: Array<{ + node: { + id: string + name: string + email: string | null + phone: string | null + createdAt: string + updatedAt: string + cancelledAt: string | null + closedAt: string | null + displayFinancialStatus: string | null + displayFulfillmentStatus: string | null + totalPriceSet: { shopMoney: { amount: string; currencyCode: string } } + lineItems: { + edges: Array<{ + node: { + title: string + quantity: number + } + }> + } + } + }> + } + } | null +} + +export const listCustomerOrders: bp.IntegrationProps['actions']['listCustomerOrders'] = async ({ + input, + client, + ctx, +}) => { + const shopify = await ShopifyClient.create({ client, ctx }) + + const query = input.status && input.status !== 'any' ? `status:${input.status}` : undefined + + const data = await shopify.query(CUSTOMER_ORDERS_QUERY, { + customerId: input.customerId, + first: input.first ?? 50, + query, + }) + + if (!data.customer) { + throw new RuntimeError(`Customer not found: ${input.customerId}`) + } + + return { orders: data.customer.orders.edges.map(({ node }) => transformOrder(node)) } +} diff --git a/integrations/shopify-admin/src/actions/list-products.ts b/integrations/shopify-admin/src/actions/list-products.ts new file mode 100644 index 00000000000..2d63e7acb65 --- /dev/null +++ b/integrations/shopify-admin/src/actions/list-products.ts @@ -0,0 +1,59 @@ +import { ShopifyClient } from '../client' +import { PRODUCTS_QUERY } from '../client/queries/admin' +import { transformProduct } from '../transformers' +import * as bp from '.botpress' + +type ProductsQueryResponse = { + products: { + edges: Array<{ + node: { + id: string + title: string + handle: string + status: string + vendor: string | null + productType: string | null + descriptionHtml: string | null + createdAt: string + updatedAt: string + onlineStoreUrl: string | null + onlineStorePreviewUrl: string | null + variants: { + edges: Array<{ + node: { + id: string + title: string + price: string + sku: string | null + inventoryQuantity: number | null + } + }> + } + } + }> + pageInfo: { + hasNextPage: boolean + endCursor: string | null + } + } +} + +export const listProducts: bp.IntegrationProps['actions']['listProducts'] = async ({ input, client, ctx }) => { + const shopify = await ShopifyClient.create({ client, ctx }) + + const data = await shopify.query(PRODUCTS_QUERY, { + first: input.first ?? 50, + query: input.query, + after: input.after, + }) + + const products = data.products.edges.map(({ node }) => transformProduct(node, shopify.shopDomain)) + + return { + products, + pageInfo: { + hasNextPage: data.products.pageInfo.hasNextPage, + endCursor: data.products.pageInfo.endCursor ?? undefined, + }, + } +} diff --git a/integrations/shopify-admin/src/actions/search-customers.ts b/integrations/shopify-admin/src/actions/search-customers.ts new file mode 100644 index 00000000000..02eba373f1b --- /dev/null +++ b/integrations/shopify-admin/src/actions/search-customers.ts @@ -0,0 +1,30 @@ +import { ShopifyClient } from '../client' +import { CUSTOMERS_QUERY } from '../client/queries/admin' +import { transformCustomer } from '../transformers' +import * as bp from '.botpress' + +type CustomersQueryResponse = { + customers: { + edges: Array<{ + node: { + id: string + firstName: string | null + lastName: string | null + email: string | null + phone: string | null + numberOfOrders: string | null + amountSpent: { amount: string; currencyCode: string } | null + createdAt: string + updatedAt: string + } + }> + } +} + +export const searchCustomers: bp.IntegrationProps['actions']['searchCustomers'] = async ({ input, client, ctx }) => { + const shopify = await ShopifyClient.create({ client, ctx }) + + const data = await shopify.query(CUSTOMERS_QUERY, { query: input.query }) + + return { customers: data.customers.edges.map(({ node }) => transformCustomer(node)) } +} diff --git a/integrations/shopify-admin/src/auth.test.ts b/integrations/shopify-admin/src/auth.test.ts new file mode 100644 index 00000000000..7d640d182d0 --- /dev/null +++ b/integrations/shopify-admin/src/auth.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id' + process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret' +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +const _expiringResponse = (overrides: Record = {}) => + new Response( + JSON.stringify({ + access_token: 'shpat_a', + refresh_token: 'shprt_r', + expires_in: 3600, + refresh_token_expires_in: 7776000, + scope: 'read_products', + ...overrides, + }), + { status: 200 } + ) + +describe('exchangeCodeForAccessToken', () => { + it('sends expiring=1 in the JSON body', async () => { + const fetchMock = vi.fn().mockResolvedValue(_expiringResponse()) + vi.stubGlobal('fetch', fetchMock) + + const { exchangeCodeForAccessToken } = await import('./auth') + await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' }) + + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body).toMatchObject({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + code: 'abc', + expiring: 1, + }) + }) + + it('returns the bundle with expiry timestamps computed from expires_in', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(_expiringResponse())) + + const { exchangeCodeForAccessToken } = await import('./auth') + const credentials = await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' }) + + expect(credentials).toEqual({ + accessToken: 'shpat_a', + refreshToken: 'shprt_r', + accessTokenExpiresAtSeconds: nowSeconds + 3600, + refreshTokenExpiresAtSeconds: nowSeconds + 7776000, + }) + }) + + it('throws when refresh_token is missing in response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(_expiringResponse({ refresh_token: undefined }))) + + const { exchangeCodeForAccessToken } = await import('./auth') + await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow( + /missing one or more required expiring-token fields/ + ) + }) + + it('throws on non-2xx with the response body in the message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Bad client_secret', { status: 401, statusText: 'Unauthorized' })) + ) + + const { exchangeCodeForAccessToken } = await import('./auth') + await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow( + /401 Unauthorized — Bad client_secret/ + ) + }) +}) + +describe('refreshAccessToken', () => { + it('sends grant_type=refresh_token and the supplied refresh_token', async () => { + const fetchMock = vi.fn().mockResolvedValue(_expiringResponse()) + vi.stubGlobal('fetch', fetchMock) + + const { refreshAccessToken } = await import('./auth') + await refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' }) + + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body).toMatchObject({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + grant_type: 'refresh_token', + refresh_token: 'shprt_old', + }) + }) + + it('returns the rotated bundle from the response', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(_expiringResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new' })) + ) + + const { refreshAccessToken } = await import('./auth') + const next = await refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' }) + + expect(next).toEqual({ + accessToken: 'shpat_new', + refreshToken: 'shprt_new', + accessTokenExpiresAtSeconds: nowSeconds + 3600, + refreshTokenExpiresAtSeconds: nowSeconds + 7776000, + }) + }) + + it('throws with re-authorize hint on non-2xx', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('refresh_token expired', { status: 401, statusText: 'Unauthorized' })) + ) + + const { refreshAccessToken } = await import('./auth') + await expect(refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' })).rejects.toThrow( + /re-authorize the integration/ + ) + }) +}) + +describe('getOrRefreshCredentials', () => { + const _stubClient = (payload: Record) => { + const setState = vi.fn().mockResolvedValue({}) + const getState = vi.fn().mockResolvedValue({ state: { payload } }) + return { setState, getState, client: { setState, getState } as any, ctx: { integrationId: 'int-1' } as any } + } + + it('returns stored credentials when access token is well within expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const { client, ctx } = _stubClient({ + shopDomain: 'example', + accessToken: 'shpat_a', + refreshToken: 'shprt_r', + accessTokenExpiresAtSeconds: nowSeconds + 3600, + refreshTokenExpiresAtSeconds: nowSeconds + 7776000, + }) + + const { getOrRefreshCredentials } = await import('./auth') + const creds = await getOrRefreshCredentials({ client, ctx }) + + expect(creds.accessToken).toBe('shpat_a') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refreshes when within 5-minute buffer of expiry and persists new credentials', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(_expiringResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new' })) + ) + + const { client, ctx, setState } = _stubClient({ + shopDomain: 'example', + accessToken: 'shpat_old', + refreshToken: 'shprt_old', + accessTokenExpiresAtSeconds: nowSeconds + 60, // within buffer + refreshTokenExpiresAtSeconds: nowSeconds + 7776000, + }) + + const { getOrRefreshCredentials } = await import('./auth') + const creds = await getOrRefreshCredentials({ client, ctx }) + + expect(creds.accessToken).toBe('shpat_new') + expect(creds.refreshToken).toBe('shprt_new') + expect(setState).toHaveBeenCalledTimes(1) + const setCall = setState.mock.calls[0]![0] + expect(setCall.payload).toMatchObject({ + shopDomain: 'example', + accessToken: 'shpat_new', + refreshToken: 'shprt_new', + }) + }) + + it('throws when refreshToken is missing from state', async () => { + const { client, ctx } = _stubClient({ shopDomain: 'example', accessToken: 'shpat_a' }) + + const { getOrRefreshCredentials } = await import('./auth') + await expect(getOrRefreshCredentials({ client, ctx })).rejects.toThrow(/credentials not found or incomplete/) + }) + + it('throws when refresh token itself has expired', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + + const { client, ctx } = _stubClient({ + shopDomain: 'example', + accessToken: 'shpat_a', + refreshToken: 'shprt_r', + accessTokenExpiresAtSeconds: nowSeconds - 100, + refreshTokenExpiresAtSeconds: nowSeconds - 1, // expired + }) + + const { getOrRefreshCredentials } = await import('./auth') + await expect(getOrRefreshCredentials({ client, ctx })).rejects.toThrow(/refresh token expired \(90-day TTL\)/) + }) +}) diff --git a/integrations/shopify-admin/src/auth.ts b/integrations/shopify-admin/src/auth.ts new file mode 100644 index 00000000000..b427808c1e4 --- /dev/null +++ b/integrations/shopify-admin/src/auth.ts @@ -0,0 +1,173 @@ +import { RuntimeError } from '@botpress/sdk' +import * as bp from '.botpress' + +const REFRESH_BUFFER_SECONDS = 300 + +const _nowSeconds = () => Math.floor(Date.now() / 1000) + +export type ShopifyCredentials = { + shopDomain: string + accessToken: string + refreshToken: string + accessTokenExpiresAtSeconds: number + refreshTokenExpiresAtSeconds: number +} + +type TokenResponse = { + access_token?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number +} + +const _parseTokenResponse = (json: TokenResponse) => { + if (!json.access_token || !json.refresh_token || !json.expires_in || !json.refresh_token_expires_in) { + throw new RuntimeError('Shopify token response is missing one or more required expiring-token fields') + } + const now = _nowSeconds() + return { + accessToken: json.access_token, + refreshToken: json.refresh_token, + accessTokenExpiresAtSeconds: now + json.expires_in, + refreshTokenExpiresAtSeconds: now + json.refresh_token_expires_in, + } +} + +/** + * Exchanges a Shopify OAuth authorization code for an expiring offline Admin access token bundle. + * + * Shopify deprecated non-expiring offline tokens for new public apps as of 2026-04-01; + * `expiring: 1` opts into the supported flow (60-min access TTL, 90-day refresh TTL). + * + * See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens + */ +export const exchangeCodeForAccessToken = async ({ + shop, + code, +}: { + shop: string + code: string +}): Promise> => { + const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: bp.secrets.SHOPIFY_CLIENT_ID, + client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET, + code, + expiring: 1, + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new RuntimeError( + `Failed to exchange authorization code for access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}` + ) + } + + return _parseTokenResponse((await response.json()) as TokenResponse) +} + +/** + * Refreshes an expiring offline Admin access token using the stored refresh token. + * Shopify rotates the refresh token on every refresh — the response always contains a new pair. + */ +export const refreshAccessToken = async ({ + shop, + refreshToken, +}: { + shop: string + refreshToken: string +}): Promise> => { + const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: bp.secrets.SHOPIFY_CLIENT_ID, + client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new RuntimeError( + `Failed to refresh Shopify admin access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}. Refresh token may have expired (90-day TTL); re-authorize the integration.` + ) + } + + return _parseTokenResponse((await response.json()) as TokenResponse) +} + +export const setCredentialsState = async ({ + client, + ctx, + credentials, +}: { + client: bp.Client + ctx: bp.Context + credentials: ShopifyCredentials +}) => { + const { state } = await client + .getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + .catch(() => ({ state: { payload: {} as Record } })) + + await client.setState({ + type: 'integration', + name: 'credentials', + id: ctx.integrationId, + payload: { ...state.payload, ...credentials }, + }) +} + +/** + * Returns valid credentials, refreshing the access token pre-emptively when within + * REFRESH_BUFFER_SECONDS of expiry. Pass `force: true` from a 401-retry path to skip + * the cached-expiry check (the server is the source of truth that the token is bad). + * Throws a re-authorize prompt if the refresh token itself has expired (90-day TTL) + * or if any required field is missing from state. + */ +export const getOrRefreshCredentials = async ({ + client, + ctx, + force = false, +}: { + client: bp.Client + ctx: bp.Context + force?: boolean +}): Promise => { + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + const { shopDomain, accessToken, refreshToken, accessTokenExpiresAtSeconds, refreshTokenExpiresAtSeconds } = + state.payload + + if ( + !shopDomain || + !accessToken || + !refreshToken || + accessTokenExpiresAtSeconds === undefined || + refreshTokenExpiresAtSeconds === undefined + ) { + throw new RuntimeError( + 'Shopify credentials not found or incomplete; re-authorize the integration via the OAuth wizard.' + ) + } + + const now = _nowSeconds() + if (now >= refreshTokenExpiresAtSeconds) { + throw new RuntimeError( + 'Shopify refresh token expired (90-day TTL); re-authorize the integration via the OAuth wizard.' + ) + } + + if (!force && now < accessTokenExpiresAtSeconds - REFRESH_BUFFER_SECONDS) { + return { shopDomain, accessToken, refreshToken, accessTokenExpiresAtSeconds, refreshTokenExpiresAtSeconds } + } + + const refreshed = await refreshAccessToken({ shop: shopDomain, refreshToken }) + const next: ShopifyCredentials = { shopDomain, ...refreshed } + await setCredentialsState({ client, ctx, credentials: next }) + return next +} diff --git a/integrations/shopify-admin/src/client/index.test.ts b/integrations/shopify-admin/src/client/index.test.ts new file mode 100644 index 00000000000..69b1dc8b259 --- /dev/null +++ b/integrations/shopify-admin/src/client/index.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id' + process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret' +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +const _stubBpClient = (payload: Record) => { + const setState = vi.fn().mockResolvedValue({}) + const getState = vi.fn().mockResolvedValue({ state: { payload } }) + return { client: { setState, getState } as any, ctx: { integrationId: 'int-1' } as any, setState, getState } +} + +describe('ShopifyClient 401 retry', () => { + it('refreshes the token and retries once when query gets 401', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + const nowSeconds = Math.floor(Date.now() / 1000) + + // 1st call (initial query): 401 → triggers refresh + // 2nd call (refresh endpoint): 200 with new token bundle + // 3rd call (retry of original query): 200 with data + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'shpat_new', + refresh_token: 'shprt_new', + expires_in: 3600, + refresh_token_expires_in: 7776000, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: { ok: true } }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const { client: bpClient, ctx } = _stubBpClient({ + shopDomain: 'example', + accessToken: 'shpat_old', + refreshToken: 'shprt_old', + accessTokenExpiresAtSeconds: nowSeconds + 3600, + refreshTokenExpiresAtSeconds: nowSeconds + 7776000, + }) + + const { ShopifyClient } = await import('./index') + const shopify = new ShopifyClient({ shopDomain: 'example', accessToken: 'shpat_old', client: bpClient, ctx }) + const result = await shopify.query('query { shop { name } }') + + expect(result).toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(3) + + // First call (failing) used the old token + const firstCallHeaders = fetchMock.mock.calls[0]![1].headers as Record + expect(firstCallHeaders['X-Shopify-Access-Token']).toBe('shpat_old') + + // Third call (retry) used the refreshed token + const thirdCallHeaders = fetchMock.mock.calls[2]![1].headers as Record + expect(thirdCallHeaders['X-Shopify-Access-Token']).toBe('shpat_new') + }) + + it('does not retry on 401 when client/ctx are not provided to the constructor', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })) + vi.stubGlobal('fetch', fetchMock) + + const { ShopifyClient } = await import('./index') + const shopify = new ShopifyClient({ shopDomain: 'example', accessToken: 'shpat_old' }) + + await expect(shopify.query('query { shop { name } }')).rejects.toThrow(/401 Unauthorized/) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/integrations/shopify-admin/src/client/index.ts b/integrations/shopify-admin/src/client/index.ts new file mode 100644 index 00000000000..ca6116878d3 --- /dev/null +++ b/integrations/shopify-admin/src/client/index.ts @@ -0,0 +1,118 @@ +import { RuntimeError } from '@botpress/sdk' +import { getOrRefreshCredentials } from '../auth' +import { WEBHOOK_SUBSCRIPTION_CREATE, WEBHOOK_SUBSCRIPTION_DELETE } from './queries/admin' +import { SHOPIFY_API_VERSION } from './queries/common' +import * as bp from '.botpress' + +type ShopifyClientProps = { + shopDomain: string + accessToken: string + client?: bp.Client + ctx?: bp.Context +} + +type CreateProps = { + client: bp.Client + ctx: bp.Context +} + +type WebhookSubscriptionCreateResponse = { + webhookSubscriptionCreate: { + webhookSubscription: { id: string } | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +type WebhookSubscriptionDeleteResponse = { + webhookSubscriptionDelete: { + deletedWebhookSubscriptionId: string | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +export class ShopifyClient { + public readonly shopDomain: string + private _accessToken: string + private readonly _client?: bp.Client + private readonly _ctx?: bp.Context + + public constructor({ shopDomain, accessToken, client, ctx }: ShopifyClientProps) { + this.shopDomain = shopDomain + this._accessToken = accessToken + this._client = client + this._ctx = ctx + } + + public static async create({ client, ctx }: CreateProps): Promise { + const { shopDomain, accessToken } = await getOrRefreshCredentials({ client, ctx }) + return new ShopifyClient({ shopDomain, accessToken, client, ctx }) + } + + public async query(graphql: string, variables: Record = {}): Promise { + return this._queryWithRetry(graphql, variables, false) + } + + private async _queryWithRetry(graphql: string, variables: Record, retried: boolean): Promise { + const url = `https://${this.shopDomain}.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Access-Token': this._accessToken, + }, + body: JSON.stringify({ query: graphql, variables }), + }) + + if (response.status === 401 && !retried && this._client && this._ctx) { + const { accessToken } = await getOrRefreshCredentials({ client: this._client, ctx: this._ctx, force: true }) + this._accessToken = accessToken + return this._queryWithRetry(graphql, variables, true) + } + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new RuntimeError(`Shopify API error: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`) + } + + const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> } + + if (json.errors?.length) { + throw new RuntimeError(`Shopify GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`) + } + + return json.data as T + } + + public async subscribeWebhook(topic: string, uri: string): Promise { + const data = await this.query(WEBHOOK_SUBSCRIPTION_CREATE, { + topic, + webhookSubscription: { + uri, + format: 'JSON', + }, + }) + + const userErrors = data.webhookSubscriptionCreate.userErrors + if (userErrors.length) { + throw new RuntimeError( + `Failed to create Shopify webhook subscription for ${topic}: ${userErrors.map((e) => e.message).join(', ')}` + ) + } + + return data.webhookSubscriptionCreate.webhookSubscription?.id ?? null + } + + public async unsubscribeWebhook(webhookId: string): Promise { + const data = await this.query(WEBHOOK_SUBSCRIPTION_DELETE, { + id: webhookId, + }) + + const userErrors = data.webhookSubscriptionDelete.userErrors + if (userErrors.length) { + throw new RuntimeError( + `Failed to delete Shopify webhook subscription ${webhookId}: ${userErrors.map((e) => e.message).join(', ')}` + ) + } + } +} diff --git a/integrations/shopify-admin/src/client/queries/admin.ts b/integrations/shopify-admin/src/client/queries/admin.ts new file mode 100644 index 00000000000..0763bbb6bb4 --- /dev/null +++ b/integrations/shopify-admin/src/client/queries/admin.ts @@ -0,0 +1,212 @@ +export const PRODUCTS_QUERY = ` + query listProducts($first: Int!, $query: String, $after: String) { + products(first: $first, query: $query, after: $after) { + edges { + node { + id + title + handle + status + vendor + productType + descriptionHtml + createdAt + updatedAt + onlineStoreUrl + onlineStorePreviewUrl + variants(first: 100) { + edges { + node { + id + title + price + sku + inventoryQuantity + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const PRODUCT_QUERY = ` + query getProduct($id: ID!) { + product(id: $id) { + id + title + handle + status + vendor + productType + descriptionHtml + createdAt + updatedAt + onlineStoreUrl + onlineStorePreviewUrl + variants(first: 100) { + edges { + node { + id + title + price + sku + inventoryQuantity + } + } + } + } + } +` + +export const CUSTOMERS_QUERY = ` + query searchCustomers($query: String!) { + customers(first: 50, query: $query) { + edges { + node { + id + firstName + lastName + email + phone + numberOfOrders + amountSpent { + amount + currencyCode + } + createdAt + updatedAt + } + } + } + } +` + +export const ORDER_QUERY = ` + query getOrder($id: ID!) { + order(id: $id) { + id + name + email + phone + createdAt + updatedAt + cancelledAt + closedAt + displayFinancialStatus + displayFulfillmentStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + lineItems(first: 100) { + edges { + node { + title + quantity + variant { + id + title + price + sku + inventoryQuantity + } + } + } + } + customer { + id + firstName + lastName + email + phone + createdAt + updatedAt + } + } + } +` + +export const CUSTOMER_ORDERS_QUERY = ` + query listCustomerOrders($customerId: ID!, $first: Int!, $query: String) { + customer(id: $customerId) { + orders(first: $first, query: $query) { + edges { + node { + id + name + email + phone + createdAt + updatedAt + cancelledAt + closedAt + displayFinancialStatus + displayFulfillmentStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + lineItems(first: 50) { + edges { + node { + title + quantity + } + } + } + } + } + } + } + } +` + +export const WEBHOOK_SUBSCRIPTION_CREATE = ` + mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) { + webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) { + webhookSubscription { + id + } + userErrors { + field + message + } + } + } +` + +export const WEBHOOK_SUBSCRIPTION_DELETE = ` + mutation webhookSubscriptionDelete($id: ID!) { + webhookSubscriptionDelete(id: $id) { + deletedWebhookSubscriptionId + userErrors { + field + message + } + } + } +` + +export const WEBHOOK_SUBSCRIPTIONS_QUERY = ` + query webhookSubscriptions($first: Int!) { + webhookSubscriptions(first: $first) { + edges { + node { + id + topic + uri + } + } + } + } +` diff --git a/integrations/shopify-admin/src/client/queries/common.ts b/integrations/shopify-admin/src/client/queries/common.ts new file mode 100644 index 00000000000..a7f4404c4a8 --- /dev/null +++ b/integrations/shopify-admin/src/client/queries/common.ts @@ -0,0 +1 @@ +export const SHOPIFY_API_VERSION = '2026-04' diff --git a/integrations/shopify-admin/src/events/order-cancelled.ts b/integrations/shopify-admin/src/events/order-cancelled.ts new file mode 100644 index 00000000000..c51e9e98ca1 --- /dev/null +++ b/integrations/shopify-admin/src/events/order-cancelled.ts @@ -0,0 +1,13 @@ +import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers' +import * as bp from '.botpress' + +type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload } + +export const fireOrderCancelled = async ({ payload, client, logger }: FireEventProps) => { + logger.forBot().info(`Received order cancelled event for order ${payload.name} (${payload.id})`) + + await client.createEvent({ + type: 'orderCancelled', + payload: transformOrderWebhookPayload(payload), + }) +} diff --git a/integrations/shopify-admin/src/events/order-created.ts b/integrations/shopify-admin/src/events/order-created.ts new file mode 100644 index 00000000000..260b0e6733c --- /dev/null +++ b/integrations/shopify-admin/src/events/order-created.ts @@ -0,0 +1,13 @@ +import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers' +import * as bp from '.botpress' + +type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload } + +export const fireOrderCreated = async ({ payload, client, logger }: FireEventProps) => { + logger.forBot().info(`Received order created event for order ${payload.name} (${payload.id})`) + + await client.createEvent({ + type: 'orderCreated', + payload: transformOrderWebhookPayload(payload), + }) +} diff --git a/integrations/shopify-admin/src/events/order-fulfilled.ts b/integrations/shopify-admin/src/events/order-fulfilled.ts new file mode 100644 index 00000000000..80547ab94b3 --- /dev/null +++ b/integrations/shopify-admin/src/events/order-fulfilled.ts @@ -0,0 +1,13 @@ +import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers' +import * as bp from '.botpress' + +type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload } + +export const fireOrderFulfilled = async ({ payload, client, logger }: FireEventProps) => { + logger.forBot().info(`Received order fulfilled event for order ${payload.name} (${payload.id})`) + + await client.createEvent({ + type: 'orderFulfilled', + payload: transformOrderWebhookPayload(payload), + }) +} diff --git a/integrations/shopify-admin/src/events/order-paid.ts b/integrations/shopify-admin/src/events/order-paid.ts new file mode 100644 index 00000000000..a37ffb06c54 --- /dev/null +++ b/integrations/shopify-admin/src/events/order-paid.ts @@ -0,0 +1,13 @@ +import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers' +import * as bp from '.botpress' + +type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload } + +export const fireOrderPaid = async ({ payload, client, logger }: FireEventProps) => { + logger.forBot().info(`Received order paid event for order ${payload.name} (${payload.id})`) + + await client.createEvent({ + type: 'orderPaid', + payload: transformOrderWebhookPayload(payload), + }) +} diff --git a/integrations/shopify-admin/src/events/order-updated.ts b/integrations/shopify-admin/src/events/order-updated.ts new file mode 100644 index 00000000000..f5908e78874 --- /dev/null +++ b/integrations/shopify-admin/src/events/order-updated.ts @@ -0,0 +1,13 @@ +import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers' +import * as bp from '.botpress' + +type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload } + +export const fireOrderUpdated = async ({ payload, client, logger }: FireEventProps) => { + logger.forBot().info(`Received order updated event for order ${payload.name} (${payload.id})`) + + await client.createEvent({ + type: 'orderUpdated', + payload: transformOrderWebhookPayload(payload), + }) +} diff --git a/integrations/shopify-admin/src/handler.test.ts b/integrations/shopify-admin/src/handler.test.ts new file mode 100644 index 00000000000..5889c2b0155 --- /dev/null +++ b/integrations/shopify-admin/src/handler.test.ts @@ -0,0 +1,97 @@ +import { createHmac } from 'crypto' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +const SECRET = 'test-shopify-secret' + +beforeAll(() => { + process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id' + process.env.SECRET_SHOPIFY_CLIENT_SECRET = SECRET +}) + +vi.mock('./events/order-created', () => ({ fireOrderCreated: vi.fn(async () => ({ status: 200, body: 'ok' })) })) +vi.mock('./events/order-updated', () => ({ fireOrderUpdated: vi.fn(async () => ({ status: 200, body: 'ok' })) })) +vi.mock('./events/order-cancelled', () => ({ fireOrderCancelled: vi.fn(async () => ({ status: 200, body: 'ok' })) })) +vi.mock('./events/order-fulfilled', () => ({ fireOrderFulfilled: vi.fn(async () => ({ status: 200, body: 'ok' })) })) +vi.mock('./events/order-paid', () => ({ fireOrderPaid: vi.fn(async () => ({ status: 200, body: 'ok' })) })) + +const computeHmac = (body: string) => createHmac('sha256', SECRET).update(body, 'utf8').digest('base64') + +const buildProps = (opts: { topic?: string; hmac?: string; body?: string; path?: string }) => { + const body = opts.body ?? '{}' + const headers: Record = {} + if (opts.topic !== undefined) headers['x-shopify-topic'] = opts.topic + if (opts.hmac !== undefined) headers['x-shopify-hmac-sha256'] = opts.hmac + const noop = () => {} + const forBot = () => ({ info: noop, warn: noop, error: noop, debug: noop }) + return { + req: { path: opts.path ?? '/', headers, body }, + logger: { forBot }, + } as any +} + +describe('Shopify webhook handler', () => { + const validBody = '{"shop_id":1,"shop_domain":"x.myshopify.com"}' + + describe('GDPR compliance topics', () => { + const topics = ['customers/data_request', 'customers/redact', 'shop/redact'] + + for (const topic of topics) { + it(`returns 200 on valid HMAC for ${topic}`, async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic, hmac: computeHmac(validBody), body: validBody })) + expect(response).toEqual({ status: 200, body: '' }) + }) + + it(`returns 401 on invalid HMAC for ${topic}`, async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic, hmac: 'invalid-hmac', body: validBody })) + expect(response).toMatchObject({ status: 401 }) + }) + } + }) + + describe('request validation', () => { + it('returns 400 when topic header is missing', async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ hmac: computeHmac(validBody), body: validBody })) + expect(response).toMatchObject({ status: 400 }) + }) + + it('returns 400 when hmac header is missing', async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic: 'customers/redact', body: validBody })) + expect(response).toMatchObject({ status: 400 }) + }) + }) + + it('returns 200 on unknown topic after HMAC passes', async () => { + const { handler } = await import('./handler') + const response = await handler( + buildProps({ topic: 'products/create', hmac: computeHmac(validBody), body: validBody }) + ) + expect(response).toEqual({ status: 200, body: '' }) + }) + + // Shopify retries non-2xx responses and disables the webhook after repeated failures, so a + // malformed payload or a transient event-dispatch error must never escalate into a 4xx/5xx. + describe('error handling returns 200 to avoid Shopify retry loops', () => { + it('returns 200 on malformed JSON body after HMAC passes', async () => { + const malformed = '{not-json' + const { handler } = await import('./handler') + const response = await handler( + buildProps({ topic: 'orders/create', hmac: computeHmac(malformed), body: malformed }) + ) + expect(response).toEqual({ status: 200, body: '' }) + }) + + it('returns 200 when an event handler throws', async () => { + const { fireOrderCreated } = await import('./events/order-created') + vi.mocked(fireOrderCreated).mockRejectedValueOnce(new Error('createEvent failed')) + const { handler } = await import('./handler') + const response = await handler( + buildProps({ topic: 'orders/create', hmac: computeHmac(validBody), body: validBody }) + ) + expect(response).toEqual({ status: 200, body: '' }) + }) + }) +}) diff --git a/integrations/shopify-admin/src/handler.ts b/integrations/shopify-admin/src/handler.ts new file mode 100644 index 00000000000..632d45b574a --- /dev/null +++ b/integrations/shopify-admin/src/handler.ts @@ -0,0 +1,67 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { fireOrderCancelled } from './events/order-cancelled' +import { fireOrderCreated } from './events/order-created' +import { fireOrderFulfilled } from './events/order-fulfilled' +import { fireOrderPaid } from './events/order-paid' +import { fireOrderUpdated } from './events/order-updated' +import { verifyWebhookHmac } from './oauth/hmac' +import { oauthWizardHandler } from './oauth/wizard' +import * as bp from '.botpress' + +const SHOPIFY_TOPIC_HEADER = 'x-shopify-topic' +const SHOPIFY_HMAC_HEADER = 'x-shopify-hmac-sha256' + +export const handler: bp.IntegrationProps['handler'] = async (props) => { + const { req, logger } = props + + if (oauthWizard.isOAuthWizardUrl(req.path)) { + return await oauthWizardHandler(props) + } + + // Webhook handling + const topic = req.headers[SHOPIFY_TOPIC_HEADER] + const hmac = req.headers[SHOPIFY_HMAC_HEADER] + + if (!topic || !hmac || !req.body) { + logger.forBot().warn('Rejected Shopify webhook: missing required headers or body') + return { status: 400, body: 'Missing Shopify webhook headers or body' } + } + + if (!verifyWebhookHmac(req.body, hmac, bp.secrets.SHOPIFY_CLIENT_SECRET)) { + logger.forBot().warn('Rejected Shopify webhook with invalid HMAC signature') + return { status: 401, body: 'Invalid HMAC signature' } + } + + try { + const payload = JSON.parse(req.body) + + switch (topic) { + case 'orders/create': + return await fireOrderCreated({ payload, ...props }) + case 'orders/updated': + return await fireOrderUpdated({ payload, ...props }) + case 'orders/cancelled': + return await fireOrderCancelled({ payload, ...props }) + case 'orders/fulfilled': + return await fireOrderFulfilled({ payload, ...props }) + case 'orders/paid': + return await fireOrderPaid({ payload, ...props }) + case 'customers/data_request': + case 'customers/redact': + case 'shop/redact': + // GDPR compliance webhooks. This integration does not persist Shopify customer data — + // Admin API responses flow straight through to bot actions. Per-shop credentials + // are cleared by unregister(); shop/redact (fired 48h after uninstall) is a safety-net no-op. + // https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance + logger.forBot().info(`Received Shopify compliance webhook: ${topic}`) + return { status: 200, body: '' } + default: + logger.forBot().warn(`Unhandled Shopify webhook topic: ${topic}`) + return { status: 200, body: '' } + } + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)) + logger.forBot().error(`Failed to process Shopify webhook (topic: ${topic}): ${error.message}`) + return { status: 200, body: '' } + } +} diff --git a/integrations/shopify-admin/src/index.ts b/integrations/shopify-admin/src/index.ts new file mode 100644 index 00000000000..6a181556439 --- /dev/null +++ b/integrations/shopify-admin/src/index.ts @@ -0,0 +1,12 @@ +import actions from './actions' +import { handler } from './handler' +import { register, unregister } from './setup' +import * as bp from '.botpress' + +export default new bp.Integration({ + register, + unregister, + actions, + channels: {}, + handler, +}) diff --git a/integrations/shopify-admin/src/oauth/hmac.test.ts b/integrations/shopify-admin/src/oauth/hmac.test.ts new file mode 100644 index 00000000000..cf42360e3f4 --- /dev/null +++ b/integrations/shopify-admin/src/oauth/hmac.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { createHmac } from 'crypto' +import { verifyOAuthCallbackHmac, verifyWebhookHmac } from './hmac' + +const SECRET = 'test-shopify-secret' + +// Helper: compute the OAuth callback HMAC exactly as the source does +const computeOAuthHmac = (params: Record, secret: string): string => { + const entries = Object.entries(params) + .filter(([k]) => k !== 'hmac' && k !== 'signature') + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + return createHmac('sha256', secret).update(message).digest('hex') +} + +// Helper: compute the webhook HMAC exactly as the source does +const computeWebhookHmac = (body: string, secret: string): string => + createHmac('sha256', secret).update(body, 'utf8').digest('base64') + +describe('verifyOAuthCallbackHmac', () => { + it('accepts a valid HMAC', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com', state: 'nonce', timestamp: '1234567890' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) + + it('returns false when hmac param is missing', () => { + const query = new URLSearchParams({ code: 'abc123', shop: 'my-store.myshopify.com' }) + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false) + }) + + it('returns false with wrong secret', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, 'wrong-secret')).toBe(false) + }) + + it('returns false when a query param is tampered', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac, shop: 'evil-store.myshopify.com' }) + + // Recompute — the hmac was computed with the original shop value + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false) + }) + + it('excludes signature param from hash input', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com', signature: 'legacy-sig' } + const hmac = computeOAuthHmac(params, SECRET) // helper already excludes signature + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) + + it('handles params with special characters', () => { + const params = { code: 'abc=123&456', shop: 'my store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) +}) + +describe('verifyWebhookHmac', () => { + const body = '{"id":12345,"name":"#1001"}' + + it('accepts a valid HMAC', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac(body, hmac, SECRET)).toBe(true) + }) + + it('returns false with wrong secret', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac(body, hmac, 'wrong-secret')).toBe(false) + }) + + it('returns false when body is tampered', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac('{"id":99999}', hmac, SECRET)).toBe(false) + }) + + it('returns false with hex-encoded HMAC instead of base64', () => { + const hexHmac = createHmac('sha256', SECRET).update(body, 'utf8').digest('hex') + expect(verifyWebhookHmac(body, hexHmac, SECRET)).toBe(false) + }) + + it('handles empty body', () => { + const hmac = computeWebhookHmac('', SECRET) + expect(verifyWebhookHmac('', hmac, SECRET)).toBe(true) + }) + + it('handles unicode body', () => { + const unicodeBody = '{"name":"Ünïcödé Shöp"}' + const hmac = computeWebhookHmac(unicodeBody, SECRET) + expect(verifyWebhookHmac(unicodeBody, hmac, SECRET)).toBe(true) + }) +}) diff --git a/integrations/shopify-admin/src/oauth/hmac.ts b/integrations/shopify-admin/src/oauth/hmac.ts new file mode 100644 index 00000000000..9c3c0a5dd18 --- /dev/null +++ b/integrations/shopify-admin/src/oauth/hmac.ts @@ -0,0 +1,48 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +/** + * Verifies the HMAC signature on Shopify's OAuth callback query string. + * + * Shopify signs the callback query params with the app's client secret. The `hmac` (and legacy + * `signature`) parameter must be removed, the remaining params sorted alphabetically by key, + * URL-encoded, and joined as `key=value&key=value`. The HMAC-SHA256 of that string (hex) must + * match the received `hmac` value. + * + * See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant + */ +export const verifyOAuthCallbackHmac = (query: URLSearchParams, secret: string): boolean => { + const receivedHmac = query.get('hmac') + if (!receivedHmac) { + return false + } + + const entries: [string, string][] = [] + for (const [key, value] of query.entries()) { + if (key === 'hmac' || key === 'signature') { + continue + } + entries.push([key, value]) + } + entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + + const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + const computed = createHmac('sha256', secret).update(message).digest() + const received = Buffer.from(receivedHmac, 'hex') + + return computed.length === received.length && timingSafeEqual(computed, received) +} + +/** + * Verifies the HMAC signature on an incoming Shopify webhook request. + * + * Shopify sends the HMAC-SHA256 of the raw request body (base64-encoded) in the + * `X-Shopify-Hmac-Sha256` header. The HMAC is computed with the app's client secret. + * + * See https://shopify.dev/docs/apps/build/webhooks/subscribe#verify-a-webhook + */ +export const verifyWebhookHmac = (rawBody: string, hmacHeader: string, secret: string): boolean => { + const computed = createHmac('sha256', secret).update(rawBody, 'utf8').digest() + const received = Buffer.from(hmacHeader, 'base64') + + return computed.length === received.length && timingSafeEqual(computed, received) +} diff --git a/integrations/shopify-admin/src/oauth/wizard.test.ts b/integrations/shopify-admin/src/oauth/wizard.test.ts new file mode 100644 index 00000000000..5015495f3f2 --- /dev/null +++ b/integrations/shopify-admin/src/oauth/wizard.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { normalizeShopDomain } from './wizard' + +describe('normalizeShopDomain', () => { + it('returns bare domain as-is', () => { + expect(normalizeShopDomain('my-store')).toBe('my-store') + }) + + it('strips .myshopify.com suffix', () => { + expect(normalizeShopDomain('my-store.myshopify.com')).toBe('my-store') + }) + + it('strips https:// protocol', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com')).toBe('my-store') + }) + + it('strips http:// protocol', () => { + expect(normalizeShopDomain('http://my-store.myshopify.com')).toBe('my-store') + }) + + it('strips trailing slash', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/')).toBe('my-store') + }) + + it('strips path segments', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/admin')).toBe('my-store') + }) + + it('strips deep path segments', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/admin/products/123')).toBe('my-store') + }) + + it('trims whitespace', () => { + expect(normalizeShopDomain(' my-store.myshopify.com ')).toBe('my-store') + }) + + it('lowercases input', () => { + expect(normalizeShopDomain('MY-STORE.MYSHOPIFY.COM')).toBe('my-store') + }) + + it('handles full URL with mixed case and whitespace', () => { + expect(normalizeShopDomain(' HTTPS://MY-STORE.MYSHOPIFY.COM/admin/products ')).toBe('my-store') + }) +}) diff --git a/integrations/shopify-admin/src/oauth/wizard.ts b/integrations/shopify-admin/src/oauth/wizard.ts new file mode 100644 index 00000000000..141d2c56b43 --- /dev/null +++ b/integrations/shopify-admin/src/oauth/wizard.ts @@ -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 + +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 => { + 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 ${shopDomain}.myshopify.com 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 => { + try { + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + return (state?.payload as CredentialsPatch | undefined) ?? {} + } catch { + return {} + } +} diff --git a/integrations/shopify-admin/src/setup.ts b/integrations/shopify-admin/src/setup.ts new file mode 100644 index 00000000000..934b85e1796 --- /dev/null +++ b/integrations/shopify-admin/src/setup.ts @@ -0,0 +1,77 @@ +import { RuntimeError } from '@botpress/sdk' +import { ShopifyClient } from './client' +import * as bp from '.botpress' + +const WEBHOOK_TOPICS = ['ORDERS_CREATE', 'ORDERS_UPDATED', 'ORDERS_CANCELLED', 'ORDERS_FULFILLED', 'ORDERS_PAID'] + +export const register: bp.IntegrationProps['register'] = async ({ client, ctx, webhookUrl, logger }) => { + logger.forBot().info('Registering Shopify Admin integration...') + + let shopify: ShopifyClient + try { + shopify = await ShopifyClient.create({ client, ctx }) + } catch { + logger + .forBot() + .info('No Shopify credentials yet — skipping webhook subscription. Complete the OAuth wizard to finish setup.') + return + } + + // Replace any previously-created subscriptions with a fresh set (e.g., after a re-auth). + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + for (const id of state.payload.webhookSubscriptionIds ?? []) { + await shopify + .unsubscribeWebhook(id) + .catch((err) => logger.forBot().warn({ err }, `Failed to delete stale webhook subscription ${id}`)) + } + + const subscriptionIds: string[] = [] + const failures: Array<{ topic: string; err: unknown }> = [] + for (const topic of WEBHOOK_TOPICS) { + try { + const id = await shopify.subscribeWebhook(topic, webhookUrl) + if (id) { + subscriptionIds.push(id) + } + } catch (err) { + failures.push({ topic, err }) + logger.forBot().warn({ err }, `Failed to subscribe to Shopify webhook topic ${topic}`) + } + } + + await client.setState({ + type: 'integration', + name: 'credentials', + id: ctx.integrationId, + payload: { ...state.payload, webhookSubscriptionIds: subscriptionIds }, + }) + + if (subscriptionIds.length === 0 && failures.length === WEBHOOK_TOPICS.length) { + throw new RuntimeError( + `All Shopify webhook subscriptions failed (${failures.length}/${WEBHOOK_TOPICS.length}); the access token is likely invalid. Re-authorize the integration.` + ) + } + + logger.forBot().info(`Shopify Admin integration registered with ${subscriptionIds.length} webhook subscription(s).`) +} + +export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ctx, logger }) => { + logger.forBot().info('Unregistering Shopify Admin integration...') + + let shopify: ShopifyClient + try { + shopify = await ShopifyClient.create({ client, ctx }) + } catch { + logger.forBot().info('No Shopify credentials — nothing to unregister.') + return + } + + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + for (const id of state.payload.webhookSubscriptionIds ?? []) { + await shopify + .unsubscribeWebhook(id) + .catch((err) => logger.forBot().warn({ err }, `Failed to delete webhook subscription ${id}`)) + } + + logger.forBot().info('Shopify Admin integration unregistered.') +} diff --git a/integrations/shopify-admin/src/transformers.test.ts b/integrations/shopify-admin/src/transformers.test.ts new file mode 100644 index 00000000000..d95c24d7e5a --- /dev/null +++ b/integrations/shopify-admin/src/transformers.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest' +import { + transformVariant, + transformProduct, + transformCustomer, + transformLineItem, + transformOrder, + transformOrderWebhookPayload, +} from './transformers' + +describe('transformVariant', () => { + it('maps all fields', () => { + const result = transformVariant({ id: 'v1', title: 'Small', price: '9.99', sku: 'SKU-1', inventoryQuantity: 10 }) + expect(result).toEqual({ id: 'v1', title: 'Small', price: '9.99', sku: 'SKU-1', inventoryQuantity: 10 }) + }) + + it('converts null sku and inventoryQuantity to undefined', () => { + const result = transformVariant({ id: 'v1', title: 'Small', price: '9.99', sku: null, inventoryQuantity: null }) + expect(result.sku).toBeUndefined() + expect(result.inventoryQuantity).toBeUndefined() + }) +}) + +describe('transformProduct', () => { + const baseProduct = { + id: 'p1', + title: 'Widget', + handle: 'widget', + status: 'ACTIVE', + vendor: 'Acme', + productType: 'Gadget', + descriptionHtml: '

Nice

', + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + onlineStoreUrl: 'https://shop.myshopify.com/products/widget', + onlineStorePreviewUrl: 'https://shop.myshopify.com/products/widget?preview=true', + variants: { edges: [{ node: { id: 'v1', title: 'Default', price: '10.00', sku: null, inventoryQuantity: 5 } }] }, + } + + it('uses onlineStoreUrl as storefrontUrl when present', () => { + const result = transformProduct(baseProduct, 'shop') + expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget') + }) + + it('falls back to onlineStorePreviewUrl when onlineStoreUrl is null', () => { + const result = transformProduct({ ...baseProduct, onlineStoreUrl: null }, 'shop') + expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget?preview=true') + }) + + it('falls back to constructed URL when both store URLs are null', () => { + const result = transformProduct({ ...baseProduct, onlineStoreUrl: null, onlineStorePreviewUrl: null }, 'my-shop') + expect(result.storefrontUrl).toBe('https://my-shop.myshopify.com/products/widget') + }) + + it('maps variants through transformVariant', () => { + const result = transformProduct(baseProduct, 'shop') + expect(result.variants).toEqual([ + { id: 'v1', title: 'Default', price: '10.00', sku: undefined, inventoryQuantity: 5 }, + ]) + }) + + it('converts null optional fields to undefined', () => { + const result = transformProduct({ ...baseProduct, vendor: null, productType: null, descriptionHtml: null }, 'shop') + expect(result.vendor).toBeUndefined() + expect(result.productType).toBeUndefined() + expect(result.descriptionHtml).toBeUndefined() + }) +}) + +describe('transformCustomer', () => { + const baseCustomer = { + id: 'c1', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + numberOfOrders: '5', + amountSpent: { amount: '100.00', currencyCode: 'USD' }, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + } + + it('formats amountSpent as "amount currencyCode"', () => { + expect(transformCustomer(baseCustomer).amountSpent).toBe('100.00 USD') + }) + + it('coerces numberOfOrders string to number', () => { + expect(transformCustomer(baseCustomer).numberOfOrders).toBe(5) + }) + + it('coerces numberOfOrders number to number', () => { + expect(transformCustomer({ ...baseCustomer, numberOfOrders: 3 }).numberOfOrders).toBe(3) + }) + + it('converts null numberOfOrders to undefined', () => { + expect(transformCustomer({ ...baseCustomer, numberOfOrders: null }).numberOfOrders).toBeUndefined() + }) + + it('converts undefined numberOfOrders to undefined', () => { + const { numberOfOrders: _, ...rest } = baseCustomer + expect(transformCustomer(rest as any).numberOfOrders).toBeUndefined() + }) + + it('converts null amountSpent to undefined', () => { + expect(transformCustomer({ ...baseCustomer, amountSpent: null }).amountSpent).toBeUndefined() + }) + + it('converts null contact fields to undefined', () => { + const result = transformCustomer({ ...baseCustomer, firstName: null, lastName: null, email: null, phone: null }) + expect(result.firstName).toBeUndefined() + expect(result.lastName).toBeUndefined() + expect(result.email).toBeUndefined() + expect(result.phone).toBeUndefined() + }) +}) + +describe('transformLineItem', () => { + it('includes transformed variant when present', () => { + const result = transformLineItem({ + title: 'Widget', + quantity: 2, + variant: { id: 'v1', title: 'Small', price: '5.00', sku: 'S1', inventoryQuantity: 10 }, + }) + expect(result.variant).toEqual({ id: 'v1', title: 'Small', price: '5.00', sku: 'S1', inventoryQuantity: 10 }) + }) + + it('converts null variant to undefined', () => { + expect(transformLineItem({ title: 'Widget', quantity: 2, variant: null }).variant).toBeUndefined() + }) + + it('converts missing variant to undefined', () => { + expect(transformLineItem({ title: 'Widget', quantity: 2 }).variant).toBeUndefined() + }) +}) + +describe('transformOrder', () => { + const baseOrder = { + id: 'o1', + name: '#1001', + email: 'buyer@example.com', + phone: '+1234567890', + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + cancelledAt: null, + closedAt: null, + displayFinancialStatus: 'PAID', + displayFulfillmentStatus: 'FULFILLED', + totalPriceSet: { shopMoney: { amount: '50.00', currencyCode: 'USD' } }, + lineItems: { + edges: [ + { + node: { + title: 'Widget', + quantity: 1, + variant: { id: 'v1', title: 'Default', price: '50.00', sku: null, inventoryQuantity: null }, + }, + }, + ], + }, + customer: { + id: 'c1', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: null, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + }, + } + + it('maps financial and fulfillment statuses', () => { + const result = transformOrder(baseOrder) + expect(result.financialStatus).toBe('PAID') + expect(result.fulfillmentStatus).toBe('FULFILLED') + }) + + it('defaults financialStatus to UNKNOWN when null', () => { + expect(transformOrder({ ...baseOrder, displayFinancialStatus: null }).financialStatus).toBe('UNKNOWN') + }) + + it('converts null fulfillmentStatus to undefined', () => { + expect(transformOrder({ ...baseOrder, displayFulfillmentStatus: null }).fulfillmentStatus).toBeUndefined() + }) + + it('transforms nested customer', () => { + const result = transformOrder(baseOrder) + expect(result.customer?.id).toBe('c1') + }) + + it('converts null customer to undefined', () => { + expect(transformOrder({ ...baseOrder, customer: null }).customer).toBeUndefined() + }) + + it('maps line items', () => { + const result = transformOrder(baseOrder) + expect(result.lineItems).toHaveLength(1) + expect(result.lineItems[0]!.title).toBe('Widget') + }) + + it('extracts totalPrice and currencyCode from totalPriceSet', () => { + const result = transformOrder(baseOrder) + expect(result.totalPrice).toBe('50.00') + expect(result.currencyCode).toBe('USD') + }) +}) + +describe('transformOrderWebhookPayload', () => { + const basePayload = { + id: 12345, + name: '#1001', + email: 'buyer@example.com', + financial_status: 'paid', + fulfillment_status: 'fulfilled', + total_price: '50.00', + currency: 'USD', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + } + + it('wraps numeric id as GID string', () => { + expect(transformOrderWebhookPayload(basePayload).id).toBe('gid://shopify/Order/12345') + }) + + it('maps snake_case fields to camelCase', () => { + const result = transformOrderWebhookPayload(basePayload) + expect(result.financialStatus).toBe('paid') + expect(result.fulfillmentStatus).toBe('fulfilled') + expect(result.totalPrice).toBe('50.00') + expect(result.currencyCode).toBe('USD') + expect(result.createdAt).toBe('2024-01-01T00:00:00Z') + expect(result.updatedAt).toBe('2024-01-02T00:00:00Z') + }) + + it('defaults financial_status to UNKNOWN when null', () => { + expect(transformOrderWebhookPayload({ ...basePayload, financial_status: null }).financialStatus).toBe('UNKNOWN') + }) + + it('converts null email and fulfillment_status to undefined', () => { + const result = transformOrderWebhookPayload({ ...basePayload, email: null, fulfillment_status: null }) + expect(result.email).toBeUndefined() + expect(result.fulfillmentStatus).toBeUndefined() + }) +}) diff --git a/integrations/shopify-admin/src/transformers.ts b/integrations/shopify-admin/src/transformers.ts new file mode 100644 index 00000000000..0ae7e5f8fcf --- /dev/null +++ b/integrations/shopify-admin/src/transformers.ts @@ -0,0 +1,147 @@ +type VariantNode = { + id: string + title: string + price: string + sku: string | null + inventoryQuantity: number | null +} + +type ProductNode = { + id: string + title: string + handle: string + status: string + vendor: string | null + productType: string | null + descriptionHtml: string | null + createdAt: string + updatedAt: string + onlineStoreUrl: string | null + onlineStorePreviewUrl: string | null + variants: { edges: Array<{ node: VariantNode }> } +} + +type MoneyV2 = { + amount: string + currencyCode: string +} + +type CustomerNode = { + id: string + firstName: string | null + lastName: string | null + email: string | null + phone: string | null + numberOfOrders?: string | number | null + amountSpent?: MoneyV2 | null + createdAt: string + updatedAt: string +} + +type LineItemNode = { + title: string + quantity: number + variant?: VariantNode | null +} + +type OrderNode = { + id: string + name: string + email: string | null + phone: string | null + createdAt: string + updatedAt: string + cancelledAt: string | null + closedAt: string | null + displayFinancialStatus: string | null + displayFulfillmentStatus: string | null + totalPriceSet: { shopMoney: MoneyV2 } + lineItems: { edges: Array<{ node: LineItemNode }> } + customer?: CustomerNode | null +} + +export const transformVariant = (node: VariantNode) => ({ + id: node.id, + title: node.title, + price: node.price, + sku: node.sku ?? undefined, + inventoryQuantity: node.inventoryQuantity ?? undefined, +}) + +export const transformProduct = (node: ProductNode, shopDomain: string) => ({ + id: node.id, + title: node.title, + handle: node.handle, + status: node.status, + vendor: node.vendor ?? undefined, + productType: node.productType ?? undefined, + descriptionHtml: node.descriptionHtml ?? undefined, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + storefrontUrl: + node.onlineStoreUrl ?? node.onlineStorePreviewUrl ?? `https://${shopDomain}.myshopify.com/products/${node.handle}`, + onlineStoreUrl: node.onlineStoreUrl ?? undefined, + onlineStorePreviewUrl: node.onlineStorePreviewUrl ?? undefined, + variants: node.variants.edges.map(({ node: variant }) => transformVariant(variant)), +}) + +export const transformCustomer = (node: CustomerNode) => ({ + id: node.id, + firstName: node.firstName ?? undefined, + lastName: node.lastName ?? undefined, + email: node.email ?? undefined, + phone: node.phone ?? undefined, + numberOfOrders: node.numberOfOrders != null ? Number(node.numberOfOrders) : undefined, + amountSpent: node.amountSpent ? `${node.amountSpent.amount} ${node.amountSpent.currencyCode}` : undefined, + createdAt: node.createdAt, + updatedAt: node.updatedAt, +}) + +export const transformLineItem = (node: LineItemNode) => ({ + title: node.title, + quantity: node.quantity, + variant: node.variant ? transformVariant(node.variant) : undefined, +}) + +export const transformOrder = (node: OrderNode) => ({ + id: node.id, + name: node.name, + email: node.email ?? undefined, + phone: node.phone ?? undefined, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + cancelledAt: node.cancelledAt ?? undefined, + closedAt: node.closedAt ?? undefined, + financialStatus: node.displayFinancialStatus ?? 'UNKNOWN', + fulfillmentStatus: node.displayFulfillmentStatus ?? undefined, + totalPrice: node.totalPriceSet.shopMoney.amount, + currencyCode: node.totalPriceSet.shopMoney.currencyCode, + lineItems: node.lineItems.edges.map(({ node: lineItem }) => transformLineItem(lineItem)), + customer: node.customer ? transformCustomer(node.customer) : undefined, +}) + +// Shopify REST/webhook order payload — only the fields we surface in orderEventSchema. +// Reference: https://shopify.dev/docs/api/webhooks?reference=admin#topic-orders-create +export type OrderWebhookPayload = { + id: number + name: string + email: string | null + financial_status: string | null + fulfillment_status: string | null + total_price: string + currency: string + created_at: string + updated_at: string +} + +export const transformOrderWebhookPayload = (payload: OrderWebhookPayload) => ({ + id: `gid://shopify/Order/${payload.id}`, + name: payload.name, + email: payload.email ?? undefined, + financialStatus: payload.financial_status ?? 'UNKNOWN', + fulfillmentStatus: payload.fulfillment_status ?? undefined, + totalPrice: payload.total_price, + currencyCode: payload.currency, + createdAt: payload.created_at, + updatedAt: payload.updated_at, +}) diff --git a/integrations/shopify-admin/tsconfig.json b/integrations/shopify-admin/tsconfig.json new file mode 100644 index 00000000000..fc664427ddf --- /dev/null +++ b/integrations/shopify-admin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "baseUrl": ".", + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts", "*.json"] +} diff --git a/integrations/shopify-admin/vitest.config.ts b/integrations/shopify-admin/vitest.config.ts new file mode 100644 index 00000000000..15790f99dc3 --- /dev/null +++ b/integrations/shopify-admin/vitest.config.ts @@ -0,0 +1,2 @@ +import config from '../../vitest.config' +export default config diff --git a/integrations/shopify-storefront/definitions/actions.ts b/integrations/shopify-storefront/definitions/actions.ts new file mode 100644 index 00000000000..2563faca470 --- /dev/null +++ b/integrations/shopify-storefront/definitions/actions.ts @@ -0,0 +1,189 @@ +import { z, IntegrationDefinitionProps } from '@botpress/sdk' +import { storefrontProductSchema, collectionSchema, cartSchema, pageInfoSchema } from './schemas' + +export const actions = { + searchProducts: { + title: 'Search Products', + description: 'Search public-facing products via the Shopify Storefront API', + input: { + schema: z.object({ + query: z.string().title('Search Query').describe('Search term to find products'), + first: z + .number() + .min(1) + .max(250) + .default(50) + .optional() + .title('Limit') + .describe('Number of products to return'), + after: z.string().optional().title('After Cursor').describe('Cursor for pagination'), + }), + }, + output: { + schema: z.object({ + products: z.array(storefrontProductSchema).title('Products').describe('List of matching products'), + pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'), + }), + }, + }, + + getProduct: { + title: 'Get Product', + description: 'Get a single product from the Shopify Storefront API by handle or ID', + input: { + schema: z.object({ + handle: z.string().optional().title('Handle').describe('The URL-friendly handle of the product'), + productId: z + .string() + .optional() + .title('Product ID') + .describe('The Storefront GID of the product (e.g. gid://shopify/Product/12345)'), + }), + }, + output: { + schema: z.object({ + product: storefrontProductSchema.title('Product').describe('The product details'), + }), + }, + }, + + listCollections: { + title: 'List Collections', + description: 'List collections from the Shopify Storefront API', + input: { + schema: z.object({ + first: z + .number() + .min(1) + .max(250) + .default(50) + .optional() + .title('Limit') + .describe('Number of collections to return'), + after: z.string().optional().title('After Cursor').describe('Cursor for pagination'), + }), + }, + output: { + schema: z.object({ + collections: z.array(collectionSchema).title('Collections').describe('List of collections'), + pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'), + }), + }, + }, + + getCollection: { + title: 'Get Collection', + description: 'Get a single collection with its products from the Shopify Storefront API', + input: { + schema: z.object({ + handle: z.string().optional().title('Handle').describe('The URL-friendly handle of the collection'), + collectionId: z + .string() + .optional() + .title('Collection ID') + .describe('The Storefront GID of the collection (e.g. gid://shopify/Collection/12345)'), + productsFirst: z + .number() + .min(0) + .max(250) + .default(50) + .optional() + .title('Products Limit') + .describe('Number of products to include'), + }), + }, + output: { + schema: z.object({ + collection: collectionSchema.title('Collection').describe('The collection details'), + products: z.array(storefrontProductSchema).title('Products').describe('Products in the collection'), + pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info for products'), + }), + }, + }, + + createCart: { + title: 'Create Cart', + description: 'Create a new cart via the Shopify Storefront API', + input: { + schema: z.object({ + lines: z + .array( + z.object({ + merchandiseId: z.string().title('Merchandise ID').describe('The Storefront GID of the product variant'), + quantity: z.number().min(1).title('Quantity').describe('The quantity to add'), + }) + ) + .title('Lines') + .describe('Cart line items to add'), + buyerEmail: z.string().optional().title('Buyer Email').describe('Email of the buyer'), + countryCode: z + .string() + .optional() + .title('Country Code') + .describe('ISO 3166-1 alpha-2 country code for the buyer'), + discountCodes: z.array(z.string()).optional().title('Discount Codes').describe('Discount codes to apply'), + note: z.string().optional().title('Note').describe('A note for the cart'), + }), + }, + output: { + schema: z.object({ + cart: cartSchema.title('Cart').describe('The created cart'), + }), + }, + }, + + getCart: { + title: 'Get Cart', + description: 'Retrieve a cart by ID from the Shopify Storefront API', + input: { + schema: z.object({ + cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'), + }), + }, + output: { + schema: z.object({ + cart: cartSchema.title('Cart').describe('The cart details'), + }), + }, + }, + + addCartLines: { + title: 'Add Cart Lines', + description: 'Add line items to an existing cart via the Shopify Storefront API', + input: { + schema: z.object({ + cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'), + lines: z + .array( + z.object({ + merchandiseId: z.string().title('Merchandise ID').describe('The Storefront GID of the product variant'), + quantity: z.number().min(1).title('Quantity').describe('The quantity to add'), + }) + ) + .title('Lines') + .describe('Line items to add to the cart'), + }), + }, + output: { + schema: z.object({ + cart: cartSchema.title('Cart').describe('The updated cart'), + }), + }, + }, + + applyCartDiscount: { + title: 'Apply Cart Discount', + description: 'Apply or update discount codes on a cart via the Shopify Storefront API', + input: { + schema: z.object({ + cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'), + discountCodes: z.array(z.string()).title('Discount Codes').describe('Discount codes to apply to the cart'), + }), + }, + output: { + schema: z.object({ + cart: cartSchema.title('Cart').describe('The updated cart'), + }), + }, + }, +} satisfies IntegrationDefinitionProps['actions'] diff --git a/integrations/shopify-storefront/definitions/index.ts b/integrations/shopify-storefront/definitions/index.ts new file mode 100644 index 00000000000..f64e12f807d --- /dev/null +++ b/integrations/shopify-storefront/definitions/index.ts @@ -0,0 +1,17 @@ +import { z } from '@botpress/sdk' + +export { actions } from './actions' +export { states } from './states' +export * as schemas from './schemas' + +export const configuration = { + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + schema: z.object({}), +} + +export const secrets = { + SHOPIFY_CLIENT_ID: { description: 'The Client ID of the Shopify app' }, + SHOPIFY_CLIENT_SECRET: { description: 'The Client Secret of the Shopify app' }, +} diff --git a/integrations/shopify-storefront/definitions/schemas.ts b/integrations/shopify-storefront/definitions/schemas.ts new file mode 100644 index 00000000000..86eef48a268 --- /dev/null +++ b/integrations/shopify-storefront/definitions/schemas.ts @@ -0,0 +1,103 @@ +import { z } from '@botpress/sdk' + +export const pageInfoSchema = z.object({ + hasNextPage: z.boolean().title('Has Next Page').describe('Whether there are more results'), + endCursor: z.string().optional().title('End Cursor').describe('Cursor for the next page'), +}) + +export const moneySchema = z.object({ + amount: z.string().title('Amount').describe('Decimal money amount'), + currencyCode: z.string().title('Currency Code').describe('ISO 4217 currency code'), +}) + +export const storefrontVariantSchema = z.object({ + id: z.string().title('Variant ID').describe(`The Storefront GID of the product + variant. This is the unique identifier for a variant within a product. This + id, in the format "gid://shopify/ProductVariant/{number}", can be used for + adding a product to a cart`), + title: z.string().title('Title').describe('The title of the variant'), + availableForSale: z.boolean().title('Available for Sale').describe('Whether the variant is available for purchase'), + price: moneySchema.title('Price').describe('The price of the variant'), +}) + +export const storefrontProductSchema = z.object({ + id: z.string().title('Product ID').describe(`The Storefront GID of the + product. Don't offer the user to add products to the chart. Only offer the + user to add specific variants to the cart`), + title: z.string().title('Title').describe('The title of the product'), + handle: z.string().title('Handle').describe('The URL-friendly handle of the product'), + description: z.string().optional().title('Description').describe('Plain-text description of the product'), + productType: z.string().optional().title('Product Type').describe('The product type'), + vendor: z.string().optional().title('Vendor').describe('The vendor of the product'), + availableForSale: z.boolean().title('Available for Sale').describe('Whether the product is available for purchase'), + priceRange: z + .object({ + minVariantPrice: moneySchema.title('Min Variant Price'), + maxVariantPrice: moneySchema.title('Max Variant Price'), + }) + .optional() + .title('Price Range') + .describe('The price range across all variants'), + variants: z.array(storefrontVariantSchema).title('Variants').describe('The product variants'), + imageUrl: z.string().optional().title('Image URL').describe('URL of the primary product image'), + storefrontUrl: z + .string() + .title('Storefront URL') + .describe("Canonical storefront URL on the shop's myshopify.com domain — always populated"), + onlineStoreUrl: z + .string() + .optional() + .title('Online Store URL') + .describe( + 'Published Online Store URL (may use a custom domain). Undefined if the product is not published to the Online Store sales channel' + ), +}) + +export const collectionSchema = z.object({ + id: z.string().title('Collection ID').describe('The Storefront GID of the collection'), + title: z.string().title('Title').describe('The title of the collection'), + handle: z.string().title('Handle').describe(`The URL-friendly handle of the + collection. Do not offer to add a collection to the cart. Collections are + groups of products. Ask the user if they want to see the procuts belonging to + a collection`), + description: z.string().optional().title('Description').describe('Plain-text description of the collection'), + imageUrl: z.string().optional().title('Image URL').describe('URL of the collection image'), +}) + +export const cartLineSchema = z.object({ + lineId: z.string().title('Line ID').describe('The GID of the cart line'), + quantity: z.number().title('Quantity').describe('The quantity of this line item'), + merchandiseId: z.string().title('Merchandise ID').describe('The GID of the product variant'), + title: z.string().title('Title').describe('The product title'), + variantTitle: z.string().optional().title('Variant Title').describe('The variant title'), + price: moneySchema.title('Price').describe('The unit price of the line item'), +}) + +export const cartSchema = z.object({ + cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'), + checkoutUrl: z + .string() + .title('Checkout URL') + .describe( + `Final checkout URL returned by Shopify, in the form + "https://{shop}.myshopify.com/checkouts/cn/{token}". Use this value + verbatim when linking the buyer to checkout - do not modify it, shorten + it, replace it, or construct your own URL from the shop domain. When + rendering an affordance, it must be a link (not a button) targeting + exactly this string.` + ), + totalQuantity: z.number().title('Total Quantity').describe('Total number of items in the cart'), + totalAmount: moneySchema.title('Total Amount').describe('The estimated total cost'), + subtotalAmount: moneySchema.title('Subtotal Amount').describe('The estimated subtotal before taxes and shipping'), + lines: z.array(cartLineSchema).title('Lines').describe('The cart line items'), + discountCodes: z + .array( + z.object({ + code: z.string().title('Code').describe('The discount code'), + applicable: z.boolean().title('Applicable').describe('Whether the discount code is currently applicable'), + }) + ) + .optional() + .title('Discount Codes') + .describe('Applied discount codes'), +}) diff --git a/integrations/shopify-storefront/definitions/states.ts b/integrations/shopify-storefront/definitions/states.ts new file mode 100644 index 00000000000..84086043945 --- /dev/null +++ b/integrations/shopify-storefront/definitions/states.ts @@ -0,0 +1,20 @@ +import { z, IntegrationDefinitionProps } from '@botpress/sdk' + +// Admin access token is intentionally not persisted: Shopify expiring offline tokens +// (April 2026) make stored values useless after 60 minutes. The token is used in-memory +// inside the OAuth wizard to provision the Storefront API token, then discarded. +// If a future feature needs admin access, run it inside the OAuth callback or trigger +// a re-auth wizard step. +export const states = { + credentials: { + type: 'integration', + schema: z.object({ + shopDomain: z.string().optional().title('Shop Domain').describe('The myshopify.com domain of the store'), + storefrontAccessToken: z + .string() + .optional() + .title('Storefront Access Token') + .describe('Storefront API access token used to authenticate Storefront GraphQL requests'), + }), + }, +} satisfies IntegrationDefinitionProps['states'] diff --git a/integrations/shopify-storefront/hub.md b/integrations/shopify-storefront/hub.md new file mode 100644 index 00000000000..362c3d35caf --- /dev/null +++ b/integrations/shopify-storefront/hub.md @@ -0,0 +1,34 @@ +Connect your Botpress chatbot with the Shopify Storefront API to power buyer-facing shopping experiences: browse products, navigate collections, and manage carts and checkout. The integration auto-provisions a Storefront API access token during OAuth so no additional configuration is required. + +For back-office access to products, customers, and orders — plus order webhooks — use the separate **Shopify Admin** integration. + +## Setup + +1. Install the Shopify Storefront integration in your bot. +2. Enter your Shopify store domain (e.g. `my-store.myshopify.com`) when prompted. +3. Click **Authorize** to connect via OAuth. You will be redirected to Shopify to grant permissions. + +Once authorized, the integration creates a Storefront API access token for this bot and stores it securely. No additional configuration is required. + +## Actions + +These actions use the Shopify Storefront API to power customer-facing shopping experiences. + +### Product and Collection Browsing + +- **Search Products** — Search the public product catalog by keyword with pagination support. +- **Get Product** — Retrieve a product by its URL handle or GID, including pricing and availability. +- **List Collections** — List all product collections with pagination. +- **Get Collection** — Retrieve a collection by handle or GID, along with its products. + +### Cart Management + +- **Create Cart** — Create a new shopping cart with line items. Optionally attach a buyer email, country code, discount codes, and a note. Returns a `checkoutUrl` that you can send to the customer. +- **Get Cart** — Retrieve the current state of a cart by its GID. +- **Add Cart Lines** — Add additional items to an existing cart. +- **Apply Cart Discount** — Apply or update discount codes on a cart. + +## Limitations + +- Pagination uses cursor-based navigation. To retrieve the next page of results, pass the `after` cursor from the previous response's `pageInfo`. +- Cart actions create Storefront API carts. Removing individual line items or updating quantities on existing lines is not yet supported; create a new cart instead. diff --git a/integrations/shopify-storefront/icon.svg b/integrations/shopify-storefront/icon.svg new file mode 100644 index 00000000000..1d0193b3a82 --- /dev/null +++ b/integrations/shopify-storefront/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/integrations/shopify-storefront/integration.definition.ts b/integrations/shopify-storefront/integration.definition.ts new file mode 100644 index 00000000000..c0f8b280c53 --- /dev/null +++ b/integrations/shopify-storefront/integration.definition.ts @@ -0,0 +1,16 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { actions, states, configuration, secrets } from './definitions' + +export default new IntegrationDefinition({ + name: 'shopify-storefront', + version: '0.1.2', + title: 'Shopify Storefront', + description: + 'Connect your Shopify store via the Storefront API to power buyer-facing product browsing, collections, and cart/checkout flows via OAuth 2.0.', + icon: 'icon.svg', + readme: 'hub.md', + configuration, + actions, + states, + secrets, +}) diff --git a/integrations/shopify-storefront/linkTemplate.vrl b/integrations/shopify-storefront/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/shopify-storefront/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/shopify-storefront/package.json b/integrations/shopify-storefront/package.json new file mode 100644 index 00000000000..b42d08167de --- /dev/null +++ b/integrations/shopify-storefront/package.json @@ -0,0 +1,19 @@ +{ + "name": "@botpresshub/shopify-storefront", + "description": "Shopify Storefront integration for Botpress", + "scripts": { + "build": "bp add -y && bp build", + "check:bplint": "bp lint", + "check:type": "tsc --noEmit", + "test": "vitest --run" + }, + "private": true, + "dependencies": { + "@botpress/common": "workspace:*", + "@botpress/sdk": "workspace:*" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@shopify/cli": "~4.1.0" + } +} diff --git a/integrations/shopify-storefront/shopify.app.production.toml b/integrations/shopify-storefront/shopify.app.production.toml new file mode 100644 index 00000000000..263313b77fa --- /dev/null +++ b/integrations/shopify-storefront/shopify.app.production.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "eb491c945dae316d4b1a669e0d217036" +name = "Botpress Storefront Connector" +application_url = "https://webhook.botpress.cloud/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.cloud/v1/interation/shopify-storefront" diff --git a/integrations/shopify-storefront/shopify.app.staging.toml b/integrations/shopify-storefront/shopify.app.staging.toml new file mode 100644 index 00000000000..04fd49a571c --- /dev/null +++ b/integrations/shopify-storefront/shopify.app.staging.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "6a194452b03f5f02f8ad2010e2f2c5fd" +name = "Storefront Integration Staging" +application_url = "https://webhook.botpress.dev/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.dev/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.dev/v1/interation/shopify-storefront" diff --git a/integrations/shopify-storefront/shopify.app.toml b/integrations/shopify-storefront/shopify.app.toml new file mode 100644 index 00000000000..263313b77fa --- /dev/null +++ b/integrations/shopify-storefront/shopify.app.toml @@ -0,0 +1,22 @@ +# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "eb491c945dae316d4b1a669e0d217036" +name = "Botpress Storefront Connector" +application_url = "https://webhook.botpress.cloud/oauth/wizard/start" +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings" +optional_scopes = [ ] +use_legacy_install_flow = false + +[auth] +redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ] + +[webhooks] +api_version = "2026-04" + +[[webhooks.subscriptions]] +compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] +uri = "https://controller.botpress.cloud/v1/interation/shopify-storefront" diff --git a/integrations/shopify-storefront/src/actions/add-cart-lines.ts b/integrations/shopify-storefront/src/actions/add-cart-lines.ts new file mode 100644 index 00000000000..3fa020f3194 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/add-cart-lines.ts @@ -0,0 +1,35 @@ +import { RuntimeError } from '@botpress/sdk' +import { CART_LINES_ADD } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { CartNode, transformCart } from '../transformers' +import * as bp from '.botpress' + +type CartLinesAddResponse = { + cartLinesAdd: { + cart: CartNode | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +export const addCartLines: bp.IntegrationProps['actions']['addCartLines'] = async ({ input, client, ctx }) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const data = await storefront.query(CART_LINES_ADD, { + cartId: input.cartId, + lines: input.lines.map((line) => ({ + merchandiseId: line.merchandiseId, + quantity: line.quantity, + })), + }) + + const userErrors = data.cartLinesAdd.userErrors + if (userErrors.length) { + throw new RuntimeError(`Failed to add cart lines: ${userErrors.map((e) => e.message).join(', ')}`) + } + + if (!data.cartLinesAdd.cart) { + throw new RuntimeError('cartLinesAdd returned no cart.') + } + + return { cart: transformCart(data.cartLinesAdd.cart) } +} diff --git a/integrations/shopify-storefront/src/actions/apply-cart-discount.ts b/integrations/shopify-storefront/src/actions/apply-cart-discount.ts new file mode 100644 index 00000000000..3be9afca88c --- /dev/null +++ b/integrations/shopify-storefront/src/actions/apply-cart-discount.ts @@ -0,0 +1,36 @@ +import { RuntimeError } from '@botpress/sdk' +import { CART_DISCOUNT_CODES_UPDATE } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { CartNode, transformCart } from '../transformers' +import * as bp from '.botpress' + +type CartDiscountCodesUpdateResponse = { + cartDiscountCodesUpdate: { + cart: CartNode | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +export const applyCartDiscount: bp.IntegrationProps['actions']['applyCartDiscount'] = async ({ + input, + client, + ctx, +}) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const data = await storefront.query(CART_DISCOUNT_CODES_UPDATE, { + cartId: input.cartId, + discountCodes: input.discountCodes, + }) + + const userErrors = data.cartDiscountCodesUpdate.userErrors + if (userErrors.length) { + throw new RuntimeError(`Failed to apply discount codes: ${userErrors.map((e) => e.message).join(', ')}`) + } + + if (!data.cartDiscountCodesUpdate.cart) { + throw new RuntimeError('cartDiscountCodesUpdate returned no cart.') + } + + return { cart: transformCart(data.cartDiscountCodesUpdate.cart) } +} diff --git a/integrations/shopify-storefront/src/actions/create-cart.ts b/integrations/shopify-storefront/src/actions/create-cart.ts new file mode 100644 index 00000000000..6c281477b63 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/create-cart.ts @@ -0,0 +1,51 @@ +import { RuntimeError } from '@botpress/sdk' +import { CART_CREATE } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { CartNode, transformCart } from '../transformers' +import * as bp from '.botpress' + +type CartCreateResponse = { + cartCreate: { + cart: CartNode | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +export const createCart: bp.IntegrationProps['actions']['createCart'] = async ({ input, client, ctx }) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const cartInput: Record = { + lines: input.lines.map((line) => ({ + merchandiseId: line.merchandiseId, + quantity: line.quantity, + })), + } + + if (input.buyerEmail || input.countryCode) { + cartInput.buyerIdentity = { + ...(input.buyerEmail && { email: input.buyerEmail }), + ...(input.countryCode && { countryCode: input.countryCode }), + } + } + + if (input.discountCodes?.length) { + cartInput.discountCodes = input.discountCodes + } + + if (input.note) { + cartInput.note = input.note + } + + const data = await storefront.query(CART_CREATE, { input: cartInput }) + + const userErrors = data.cartCreate.userErrors + if (userErrors.length) { + throw new RuntimeError(`Failed to create cart: ${userErrors.map((e) => e.message).join(', ')}`) + } + + if (!data.cartCreate.cart) { + throw new RuntimeError('Cart creation returned no cart.') + } + + return { cart: transformCart(data.cartCreate.cart) } +} diff --git a/integrations/shopify-storefront/src/actions/get-cart.ts b/integrations/shopify-storefront/src/actions/get-cart.ts new file mode 100644 index 00000000000..296420111f3 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/get-cart.ts @@ -0,0 +1,21 @@ +import { RuntimeError } from '@botpress/sdk' +import { CART_QUERY } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { CartNode, transformCart } from '../transformers' +import * as bp from '.botpress' + +type CartQueryResponse = { + cart: CartNode | null +} + +export const getCart: bp.IntegrationProps['actions']['getCart'] = async ({ input, client, ctx }) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const data = await storefront.query(CART_QUERY, { id: input.cartId }) + + if (!data.cart) { + throw new RuntimeError(`Cart not found: ${input.cartId}`) + } + + return { cart: transformCart(data.cart) } +} diff --git a/integrations/shopify-storefront/src/actions/get-collection.ts b/integrations/shopify-storefront/src/actions/get-collection.ts new file mode 100644 index 00000000000..3382eef8597 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/get-collection.ts @@ -0,0 +1,76 @@ +import { RuntimeError } from '@botpress/sdk' +import { STOREFRONT_GET_COLLECTION_BY_HANDLE, STOREFRONT_GET_COLLECTION_BY_ID } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { transformCollection, transformStorefrontProduct } from '../transformers' +import * as bp from '.botpress' + +type CollectionResponse = { + collection: { + id: string + title: string + handle: string + description: string | null + image: { url: string; altText: string | null } | null + products: { + edges: Array<{ + node: { + id: string + title: string + handle: string + description: string | null + productType: string | null + vendor: string | null + availableForSale: boolean + onlineStoreUrl: string | null + priceRange?: { + minVariantPrice: { amount: string; currencyCode: string } + maxVariantPrice: { amount: string; currencyCode: string } + } + variants: { + edges: Array<{ + node: { + id: string + title: string + availableForSale: boolean + price: { amount: string; currencyCode: string } + } + }> + } + images: { edges: Array<{ node: { url: string; altText: string | null } }> } + } + }> + pageInfo: { + hasNextPage: boolean + endCursor: string | null + } + } + } | null +} + +export const getCollection: bp.IntegrationProps['actions']['getCollection'] = async ({ input, client, ctx }) => { + if (!input.handle && !input.collectionId) { + throw new RuntimeError('Either "handle" or "collectionId" must be provided.') + } + + const storefront = await StorefrontClient.create({ client, ctx }) + + const query = input.handle ? STOREFRONT_GET_COLLECTION_BY_HANDLE : STOREFRONT_GET_COLLECTION_BY_ID + const variables = input.handle + ? { handle: input.handle, productsFirst: input.productsFirst ?? 50 } + : { id: input.collectionId, productsFirst: input.productsFirst ?? 50 } + + const data = await storefront.query(query, variables) + + if (!data.collection) { + throw new RuntimeError(`Collection not found: ${input.handle ?? input.collectionId}`) + } + + return { + collection: transformCollection(data.collection), + products: data.collection.products.edges.map(({ node }) => transformStorefrontProduct(node, storefront.shopDomain)), + pageInfo: { + hasNextPage: data.collection.products.pageInfo.hasNextPage, + endCursor: data.collection.products.pageInfo.endCursor ?? undefined, + }, + } +} diff --git a/integrations/shopify-storefront/src/actions/get-product.ts b/integrations/shopify-storefront/src/actions/get-product.ts new file mode 100644 index 00000000000..00ff2f6bd16 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/get-product.ts @@ -0,0 +1,52 @@ +import { RuntimeError } from '@botpress/sdk' +import { STOREFRONT_GET_PRODUCT_BY_HANDLE, STOREFRONT_GET_PRODUCT_BY_ID } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { transformStorefrontProduct } from '../transformers' +import * as bp from '.botpress' + +type ProductResponse = { + product: { + id: string + title: string + handle: string + description: string | null + productType: string | null + vendor: string | null + availableForSale: boolean + onlineStoreUrl: string | null + priceRange?: { + minVariantPrice: { amount: string; currencyCode: string } + maxVariantPrice: { amount: string; currencyCode: string } + } + variants: { + edges: Array<{ + node: { + id: string + title: string + availableForSale: boolean + price: { amount: string; currencyCode: string } + } + }> + } + images: { edges: Array<{ node: { url: string; altText: string | null } }> } + } | null +} + +export const getProduct: bp.IntegrationProps['actions']['getProduct'] = async ({ input, client, ctx }) => { + if (!input.handle && !input.productId) { + throw new RuntimeError('Either "handle" or "productId" must be provided.') + } + + const storefront = await StorefrontClient.create({ client, ctx }) + + const query = input.handle ? STOREFRONT_GET_PRODUCT_BY_HANDLE : STOREFRONT_GET_PRODUCT_BY_ID + const variables = input.handle ? { handle: input.handle } : { id: input.productId } + + const data = await storefront.query(query, variables) + + if (!data.product) { + throw new RuntimeError(`Product not found: ${input.handle ?? input.productId}`) + } + + return { product: transformStorefrontProduct(data.product, storefront.shopDomain) } +} diff --git a/integrations/shopify-storefront/src/actions/index.ts b/integrations/shopify-storefront/src/actions/index.ts new file mode 100644 index 00000000000..b605b1198b9 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/index.ts @@ -0,0 +1,20 @@ +import { addCartLines } from './add-cart-lines' +import { applyCartDiscount } from './apply-cart-discount' +import { createCart } from './create-cart' +import { getCart } from './get-cart' +import { getCollection } from './get-collection' +import { getProduct } from './get-product' +import { listCollections } from './list-collections' +import { searchProducts } from './search-products' +import * as bp from '.botpress' + +export default { + searchProducts, + getProduct, + listCollections, + getCollection, + createCart, + getCart, + addCartLines, + applyCartDiscount, +} satisfies bp.IntegrationProps['actions'] diff --git a/integrations/shopify-storefront/src/actions/list-collections.ts b/integrations/shopify-storefront/src/actions/list-collections.ts new file mode 100644 index 00000000000..07993a6db58 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/list-collections.ts @@ -0,0 +1,39 @@ +import { STOREFRONT_LIST_COLLECTIONS } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { transformCollection } from '../transformers' +import * as bp from '.botpress' + +type CollectionsResponse = { + collections: { + edges: Array<{ + node: { + id: string + title: string + handle: string + description: string | null + image: { url: string; altText: string | null } | null + } + }> + pageInfo: { + hasNextPage: boolean + endCursor: string | null + } + } +} + +export const listCollections: bp.IntegrationProps['actions']['listCollections'] = async ({ input, client, ctx }) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const data = await storefront.query(STOREFRONT_LIST_COLLECTIONS, { + first: input.first ?? 50, + after: input.after, + }) + + return { + collections: data.collections.edges.map(({ node }) => transformCollection(node)), + pageInfo: { + hasNextPage: data.collections.pageInfo.hasNextPage, + endCursor: data.collections.pageInfo.endCursor ?? undefined, + }, + } +} diff --git a/integrations/shopify-storefront/src/actions/search-products.ts b/integrations/shopify-storefront/src/actions/search-products.ts new file mode 100644 index 00000000000..4165f5f3b11 --- /dev/null +++ b/integrations/shopify-storefront/src/actions/search-products.ts @@ -0,0 +1,58 @@ +import { STOREFRONT_SEARCH_PRODUCTS } from '../client/queries/storefront' +import { StorefrontClient } from '../client/storefront' +import { transformStorefrontProduct } from '../transformers' +import * as bp from '.botpress' + +type SearchProductsResponse = { + search: { + edges: Array<{ + node: { + id: string + title: string + handle: string + description: string | null + productType: string | null + vendor: string | null + availableForSale: boolean + onlineStoreUrl: string | null + priceRange?: { + minVariantPrice: { amount: string; currencyCode: string } + maxVariantPrice: { amount: string; currencyCode: string } + } + variants: { + edges: Array<{ + node: { + id: string + title: string + availableForSale: boolean + price: { amount: string; currencyCode: string } + } + }> + } + images: { edges: Array<{ node: { url: string; altText: string | null } }> } + } + }> + pageInfo: { + hasNextPage: boolean + endCursor: string | null + } + } +} + +export const searchProducts: bp.IntegrationProps['actions']['searchProducts'] = async ({ input, client, ctx }) => { + const storefront = await StorefrontClient.create({ client, ctx }) + + const data = await storefront.query(STOREFRONT_SEARCH_PRODUCTS, { + query: input.query, + first: input.first ?? 50, + after: input.after, + }) + + return { + products: data.search.edges.map(({ node }) => transformStorefrontProduct(node, storefront.shopDomain)), + pageInfo: { + hasNextPage: data.search.pageInfo.hasNextPage, + endCursor: data.search.pageInfo.endCursor ?? undefined, + }, + } +} diff --git a/integrations/shopify-storefront/src/client/index.test.ts b/integrations/shopify-storefront/src/client/index.test.ts new file mode 100644 index 00000000000..dd8e4a8440c --- /dev/null +++ b/integrations/shopify-storefront/src/client/index.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id' + process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret' +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('exchangeCodeForAccessToken', () => { + it('sends expiring=1 in the JSON body', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ access_token: 'shpat_x' }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const { exchangeCodeForAccessToken } = await import('./index') + await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' }) + + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body).toMatchObject({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + code: 'abc', + expiring: 1, + }) + }) + + it('returns the access_token from the response', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ access_token: 'shpat_x', expires_in: 3600 }), { status: 200 })) + ) + + const { exchangeCodeForAccessToken } = await import('./index') + await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).resolves.toBe('shpat_x') + }) + + it('throws when access_token is missing from the response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ scope: 'x' }), { status: 200 }))) + + const { exchangeCodeForAccessToken } = await import('./index') + await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow(/access_token/) + }) +}) diff --git a/integrations/shopify-storefront/src/client/index.ts b/integrations/shopify-storefront/src/client/index.ts new file mode 100644 index 00000000000..ee889aef082 --- /dev/null +++ b/integrations/shopify-storefront/src/client/index.ts @@ -0,0 +1,93 @@ +import { RuntimeError } from '@botpress/sdk' +import { SHOPIFY_API_VERSION } from './queries/common' +import * as bp from '.botpress' + +type ShopifyAdminClientProps = { + shopDomain: string + accessToken: string +} + +// Minimal Admin GraphQL client, used only to provision a Storefront Access Token during OAuth. +// Runtime actions use `StorefrontClient` from `./storefront`, not this class. +export class ShopifyAdminClient { + public readonly shopDomain: string + private readonly _accessToken: string + + public constructor({ shopDomain, accessToken }: ShopifyAdminClientProps) { + this.shopDomain = shopDomain + this._accessToken = accessToken + } + + public async query(graphql: string, variables: Record = {}): Promise { + const url = `https://${this.shopDomain}.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Access-Token': this._accessToken, + }, + body: JSON.stringify({ query: graphql, variables }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new RuntimeError(`Shopify API error: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`) + } + + const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> } + + if (json.errors?.length) { + throw new RuntimeError(`Shopify GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`) + } + + return json.data as T + } +} + +/** + * Exchanges a Shopify OAuth authorization code for an expiring offline Admin access token. + * + * Shopify deprecated non-expiring offline tokens for new public apps as of 2026-04-01; + * `expiring: 1` opts into the supported flow (60-min access TTL, 90-day refresh TTL). + * This integration uses the token only inside the OAuth wizard to provision a Storefront + * Access Token, so the access TTL is not material and no refresh logic is needed. + * + * See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens + */ +export const exchangeCodeForAccessToken = async ({ shop, code }: { shop: string; code: string }): Promise => { + const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: bp.secrets.SHOPIFY_CLIENT_ID, + client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET, + code, + expiring: 1, + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new RuntimeError( + `Failed to exchange authorization code for access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}` + ) + } + + const json = (await response.json()) as { + access_token?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number + } + + if (!json.access_token) { + throw new RuntimeError('Shopify did not return an access_token in the token exchange response') + } + + return json.access_token +} diff --git a/integrations/shopify-storefront/src/client/queries/admin.ts b/integrations/shopify-storefront/src/client/queries/admin.ts new file mode 100644 index 00000000000..e9c4adb99c1 --- /dev/null +++ b/integrations/shopify-storefront/src/client/queries/admin.ts @@ -0,0 +1,34 @@ +// Admin GraphQL mutations used solely to provision a Storefront API access token after OAuth. +// The Storefront integration does not otherwise touch the Admin API at runtime. + +export const STOREFRONT_ACCESS_TOKENS_QUERY = ` + query storefrontAccessTokens { + shop { + storefrontAccessTokens(first: 10) { + edges { + node { + id + title + accessToken + } + } + } + } + } +` + +export const STOREFRONT_ACCESS_TOKEN_CREATE = ` + mutation storefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) { + storefrontAccessTokenCreate(input: $input) { + storefrontAccessToken { + id + title + accessToken + } + userErrors { + field + message + } + } + } +` diff --git a/integrations/shopify-storefront/src/client/queries/common.ts b/integrations/shopify-storefront/src/client/queries/common.ts new file mode 100644 index 00000000000..a7f4404c4a8 --- /dev/null +++ b/integrations/shopify-storefront/src/client/queries/common.ts @@ -0,0 +1 @@ +export const SHOPIFY_API_VERSION = '2026-04' diff --git a/integrations/shopify-storefront/src/client/queries/storefront.ts b/integrations/shopify-storefront/src/client/queries/storefront.ts new file mode 100644 index 00000000000..1974f3919b2 --- /dev/null +++ b/integrations/shopify-storefront/src/client/queries/storefront.ts @@ -0,0 +1,209 @@ +const PRODUCT_FIELDS = ` + id + title + handle + description + productType + vendor + availableForSale + onlineStoreUrl + priceRange { + minVariantPrice { amount currencyCode } + maxVariantPrice { amount currencyCode } + } + variants(first: 10) { + edges { + node { + id + title + availableForSale + price { amount currencyCode } + } + } + } + images(first: 1) { + edges { + node { url altText } + } + } +` + +const CART_FIELDS = ` + id + checkoutUrl + totalQuantity + cost { + totalAmount { amount currencyCode } + subtotalAmount { amount currencyCode } + } + lines(first: 100) { + edges { + node { + id + quantity + merchandise { + ... on ProductVariant { + id + title + price { amount currencyCode } + product { title } + } + } + } + } + } + discountCodes { + code + applicable + } +` + +export const STOREFRONT_SEARCH_PRODUCTS = ` + query searchProducts($query: String!, $first: Int!, $after: String) { + search(query: $query, types: PRODUCT, first: $first, after: $after) { + edges { + node { + ... on Product { + ${PRODUCT_FIELDS} + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const STOREFRONT_GET_PRODUCT_BY_HANDLE = ` + query getProductByHandle($handle: String!) { + product(handle: $handle) { + ${PRODUCT_FIELDS} + } + } +` + +export const STOREFRONT_GET_PRODUCT_BY_ID = ` + query getProductById($id: ID!) { + product(id: $id) { + ${PRODUCT_FIELDS} + } + } +` + +export const STOREFRONT_LIST_COLLECTIONS = ` + query listCollections($first: Int!, $after: String) { + collections(first: $first, after: $after) { + edges { + node { + id + title + handle + description + image { url altText } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const STOREFRONT_GET_COLLECTION_BY_HANDLE = ` + query getCollectionByHandle($handle: String!, $productsFirst: Int!) { + collection(handle: $handle) { + id + title + handle + description + image { url altText } + products(first: $productsFirst) { + edges { + node { + ${PRODUCT_FIELDS} + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const STOREFRONT_GET_COLLECTION_BY_ID = ` + query getCollectionById($id: ID!, $productsFirst: Int!) { + collection(id: $id) { + id + title + handle + description + image { url altText } + products(first: $productsFirst) { + edges { + node { + ${PRODUCT_FIELDS} + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const CART_CREATE = ` + mutation cartCreate($input: CartInput!) { + cartCreate(input: $input) { + cart { + ${CART_FIELDS} + } + userErrors { + field + message + } + } + } +` + +export const CART_QUERY = ` + query getCart($id: ID!) { + cart(id: $id) { + ${CART_FIELDS} + } + } +` + +export const CART_LINES_ADD = ` + mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) { + cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { + ${CART_FIELDS} + } + userErrors { + field + message + } + } + } +` + +export const CART_DISCOUNT_CODES_UPDATE = ` + mutation cartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]) { + cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { + cart { + ${CART_FIELDS} + } + userErrors { + field + message + } + } + } +` diff --git a/integrations/shopify-storefront/src/client/storefront.ts b/integrations/shopify-storefront/src/client/storefront.ts new file mode 100644 index 00000000000..ced8237ca34 --- /dev/null +++ b/integrations/shopify-storefront/src/client/storefront.ts @@ -0,0 +1,67 @@ +import { RuntimeError } from '@botpress/sdk' +import { SHOPIFY_API_VERSION } from './queries/common' +import * as bp from '.botpress' + +type StorefrontClientProps = { + shopDomain: string + storefrontAccessToken: string +} + +type CreateProps = { + client: bp.Client + ctx: bp.Context +} + +export class StorefrontClient { + public readonly shopDomain: string + private readonly _storefrontAccessToken: string + + public constructor({ shopDomain, storefrontAccessToken }: StorefrontClientProps) { + this.shopDomain = shopDomain + this._storefrontAccessToken = storefrontAccessToken + } + + public static async create({ client, ctx }: CreateProps): Promise { + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + + if (!state.payload.shopDomain) { + throw new RuntimeError('Shopify credentials not found. Please complete the OAuth flow first.') + } + + if (!state.payload.storefrontAccessToken) { + throw new RuntimeError( + 'Storefront access token not found. The integration may need to be re-installed to provision Storefront API access.' + ) + } + + return new StorefrontClient({ + shopDomain: state.payload.shopDomain, + storefrontAccessToken: state.payload.storefrontAccessToken, + }) + } + + public async query(graphql: string, variables: Record = {}): Promise { + const url = `https://${this.shopDomain}.myshopify.com/api/${SHOPIFY_API_VERSION}/graphql.json` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Storefront-Access-Token': this._storefrontAccessToken, + }, + body: JSON.stringify({ query: graphql, variables }), + }) + + if (!response.ok) { + throw new RuntimeError(`Shopify Storefront API error: ${response.status} ${response.statusText}`) + } + + const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> } + + if (json.errors?.length) { + throw new RuntimeError(`Shopify Storefront GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`) + } + + return json.data as T + } +} diff --git a/integrations/shopify-storefront/src/handler.test.ts b/integrations/shopify-storefront/src/handler.test.ts new file mode 100644 index 00000000000..43371917420 --- /dev/null +++ b/integrations/shopify-storefront/src/handler.test.ts @@ -0,0 +1,68 @@ +import { createHmac } from 'crypto' +import { beforeAll, describe, expect, it } from 'vitest' + +const SECRET = 'test-shopify-secret' + +beforeAll(() => { + process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id' + process.env.SECRET_SHOPIFY_CLIENT_SECRET = SECRET +}) + +const computeHmac = (body: string) => createHmac('sha256', SECRET).update(body, 'utf8').digest('base64') + +const buildProps = (opts: { topic?: string; hmac?: string; body?: string; path?: string }) => { + const body = opts.body ?? '{}' + const headers: Record = {} + if (opts.topic !== undefined) headers['x-shopify-topic'] = opts.topic + if (opts.hmac !== undefined) headers['x-shopify-hmac-sha256'] = opts.hmac + const noop = () => {} + const forBot = () => ({ info: noop, warn: noop, error: noop, debug: noop }) + return { + req: { path: opts.path ?? '/', headers, body }, + logger: { forBot }, + } as any +} + +describe('Shopify Storefront webhook handler', () => { + const validBody = '{"shop_id":1,"shop_domain":"x.myshopify.com"}' + + describe('GDPR compliance topics', () => { + const topics = ['customers/data_request', 'customers/redact', 'shop/redact'] + + for (const topic of topics) { + it(`returns 200 on valid HMAC for ${topic}`, async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic, hmac: computeHmac(validBody), body: validBody })) + expect(response).toEqual({ status: 200, body: '' }) + }) + + it(`returns 401 on invalid HMAC for ${topic}`, async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic, hmac: 'invalid-hmac', body: validBody })) + expect(response).toMatchObject({ status: 401 }) + }) + } + }) + + describe('request validation', () => { + it('returns 400 when topic header is missing', async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ hmac: computeHmac(validBody), body: validBody })) + expect(response).toMatchObject({ status: 400 }) + }) + + it('returns 400 when hmac header is missing', async () => { + const { handler } = await import('./handler') + const response = await handler(buildProps({ topic: 'customers/redact', body: validBody })) + expect(response).toMatchObject({ status: 400 }) + }) + }) + + it('returns 200 on unknown topic after HMAC passes', async () => { + const { handler } = await import('./handler') + const response = await handler( + buildProps({ topic: 'products/create', hmac: computeHmac(validBody), body: validBody }) + ) + expect(response).toEqual({ status: 200, body: '' }) + }) +}) diff --git a/integrations/shopify-storefront/src/handler.ts b/integrations/shopify-storefront/src/handler.ts new file mode 100644 index 00000000000..0c65d4bc94d --- /dev/null +++ b/integrations/shopify-storefront/src/handler.ts @@ -0,0 +1,45 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { verifyWebhookHmac } from './oauth/hmac' +import { oauthWizardHandler } from './oauth/wizard' +import * as bp from '.botpress' + +const SHOPIFY_TOPIC_HEADER = 'x-shopify-topic' +const SHOPIFY_HMAC_HEADER = 'x-shopify-hmac-sha256' + +export const handler: bp.IntegrationProps['handler'] = async (props) => { + const { req, logger } = props + + if (oauthWizard.isOAuthWizardUrl(req.path)) { + return await oauthWizardHandler(props) + } + + // Storefront API has no business webhooks, but Shopify requires every app to respond to the + // three GDPR compliance topics — otherwise the app is flagged during review. + const topic = req.headers[SHOPIFY_TOPIC_HEADER] + const hmac = req.headers[SHOPIFY_HMAC_HEADER] + + if (!topic || !hmac || !req.body) { + logger.forBot().warn('Rejected Shopify webhook: missing required headers or body') + return { status: 400, body: 'Missing Shopify webhook headers or body' } + } + + if (!verifyWebhookHmac(req.body, hmac, bp.secrets.SHOPIFY_CLIENT_SECRET)) { + logger.forBot().warn('Rejected Shopify webhook with invalid HMAC signature') + return { status: 401, body: 'Invalid HMAC signature' } + } + + switch (topic) { + case 'customers/data_request': + case 'customers/redact': + case 'shop/redact': + // GDPR compliance webhooks. This integration does not persist Shopify customer data — + // Storefront API responses flow straight through to bot actions. Per-shop credentials + // are cleared by unregister(); shop/redact (fired 48h after uninstall) is a safety-net no-op. + // https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance + logger.forBot().info(`Received Shopify compliance webhook: ${topic}`) + return { status: 200, body: '' } + default: + logger.forBot().warn(`Unhandled Shopify webhook topic: ${topic}`) + return { status: 200, body: '' } + } +} diff --git a/integrations/shopify-storefront/src/index.ts b/integrations/shopify-storefront/src/index.ts new file mode 100644 index 00000000000..6a181556439 --- /dev/null +++ b/integrations/shopify-storefront/src/index.ts @@ -0,0 +1,12 @@ +import actions from './actions' +import { handler } from './handler' +import { register, unregister } from './setup' +import * as bp from '.botpress' + +export default new bp.Integration({ + register, + unregister, + actions, + channels: {}, + handler, +}) diff --git a/integrations/shopify-storefront/src/oauth/hmac.test.ts b/integrations/shopify-storefront/src/oauth/hmac.test.ts new file mode 100644 index 00000000000..cf42360e3f4 --- /dev/null +++ b/integrations/shopify-storefront/src/oauth/hmac.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { createHmac } from 'crypto' +import { verifyOAuthCallbackHmac, verifyWebhookHmac } from './hmac' + +const SECRET = 'test-shopify-secret' + +// Helper: compute the OAuth callback HMAC exactly as the source does +const computeOAuthHmac = (params: Record, secret: string): string => { + const entries = Object.entries(params) + .filter(([k]) => k !== 'hmac' && k !== 'signature') + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + return createHmac('sha256', secret).update(message).digest('hex') +} + +// Helper: compute the webhook HMAC exactly as the source does +const computeWebhookHmac = (body: string, secret: string): string => + createHmac('sha256', secret).update(body, 'utf8').digest('base64') + +describe('verifyOAuthCallbackHmac', () => { + it('accepts a valid HMAC', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com', state: 'nonce', timestamp: '1234567890' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) + + it('returns false when hmac param is missing', () => { + const query = new URLSearchParams({ code: 'abc123', shop: 'my-store.myshopify.com' }) + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false) + }) + + it('returns false with wrong secret', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, 'wrong-secret')).toBe(false) + }) + + it('returns false when a query param is tampered', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac, shop: 'evil-store.myshopify.com' }) + + // Recompute — the hmac was computed with the original shop value + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false) + }) + + it('excludes signature param from hash input', () => { + const params = { code: 'abc123', shop: 'my-store.myshopify.com', signature: 'legacy-sig' } + const hmac = computeOAuthHmac(params, SECRET) // helper already excludes signature + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) + + it('handles params with special characters', () => { + const params = { code: 'abc=123&456', shop: 'my store.myshopify.com' } + const hmac = computeOAuthHmac(params, SECRET) + const query = new URLSearchParams({ ...params, hmac }) + + expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true) + }) +}) + +describe('verifyWebhookHmac', () => { + const body = '{"id":12345,"name":"#1001"}' + + it('accepts a valid HMAC', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac(body, hmac, SECRET)).toBe(true) + }) + + it('returns false with wrong secret', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac(body, hmac, 'wrong-secret')).toBe(false) + }) + + it('returns false when body is tampered', () => { + const hmac = computeWebhookHmac(body, SECRET) + expect(verifyWebhookHmac('{"id":99999}', hmac, SECRET)).toBe(false) + }) + + it('returns false with hex-encoded HMAC instead of base64', () => { + const hexHmac = createHmac('sha256', SECRET).update(body, 'utf8').digest('hex') + expect(verifyWebhookHmac(body, hexHmac, SECRET)).toBe(false) + }) + + it('handles empty body', () => { + const hmac = computeWebhookHmac('', SECRET) + expect(verifyWebhookHmac('', hmac, SECRET)).toBe(true) + }) + + it('handles unicode body', () => { + const unicodeBody = '{"name":"Ünïcödé Shöp"}' + const hmac = computeWebhookHmac(unicodeBody, SECRET) + expect(verifyWebhookHmac(unicodeBody, hmac, SECRET)).toBe(true) + }) +}) diff --git a/integrations/shopify-storefront/src/oauth/hmac.ts b/integrations/shopify-storefront/src/oauth/hmac.ts new file mode 100644 index 00000000000..9c3c0a5dd18 --- /dev/null +++ b/integrations/shopify-storefront/src/oauth/hmac.ts @@ -0,0 +1,48 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +/** + * Verifies the HMAC signature on Shopify's OAuth callback query string. + * + * Shopify signs the callback query params with the app's client secret. The `hmac` (and legacy + * `signature`) parameter must be removed, the remaining params sorted alphabetically by key, + * URL-encoded, and joined as `key=value&key=value`. The HMAC-SHA256 of that string (hex) must + * match the received `hmac` value. + * + * See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant + */ +export const verifyOAuthCallbackHmac = (query: URLSearchParams, secret: string): boolean => { + const receivedHmac = query.get('hmac') + if (!receivedHmac) { + return false + } + + const entries: [string, string][] = [] + for (const [key, value] of query.entries()) { + if (key === 'hmac' || key === 'signature') { + continue + } + entries.push([key, value]) + } + entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + + const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + const computed = createHmac('sha256', secret).update(message).digest() + const received = Buffer.from(receivedHmac, 'hex') + + return computed.length === received.length && timingSafeEqual(computed, received) +} + +/** + * Verifies the HMAC signature on an incoming Shopify webhook request. + * + * Shopify sends the HMAC-SHA256 of the raw request body (base64-encoded) in the + * `X-Shopify-Hmac-Sha256` header. The HMAC is computed with the app's client secret. + * + * See https://shopify.dev/docs/apps/build/webhooks/subscribe#verify-a-webhook + */ +export const verifyWebhookHmac = (rawBody: string, hmacHeader: string, secret: string): boolean => { + const computed = createHmac('sha256', secret).update(rawBody, 'utf8').digest() + const received = Buffer.from(hmacHeader, 'base64') + + return computed.length === received.length && timingSafeEqual(computed, received) +} diff --git a/integrations/shopify-storefront/src/oauth/wizard.test.ts b/integrations/shopify-storefront/src/oauth/wizard.test.ts new file mode 100644 index 00000000000..5015495f3f2 --- /dev/null +++ b/integrations/shopify-storefront/src/oauth/wizard.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { normalizeShopDomain } from './wizard' + +describe('normalizeShopDomain', () => { + it('returns bare domain as-is', () => { + expect(normalizeShopDomain('my-store')).toBe('my-store') + }) + + it('strips .myshopify.com suffix', () => { + expect(normalizeShopDomain('my-store.myshopify.com')).toBe('my-store') + }) + + it('strips https:// protocol', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com')).toBe('my-store') + }) + + it('strips http:// protocol', () => { + expect(normalizeShopDomain('http://my-store.myshopify.com')).toBe('my-store') + }) + + it('strips trailing slash', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/')).toBe('my-store') + }) + + it('strips path segments', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/admin')).toBe('my-store') + }) + + it('strips deep path segments', () => { + expect(normalizeShopDomain('https://my-store.myshopify.com/admin/products/123')).toBe('my-store') + }) + + it('trims whitespace', () => { + expect(normalizeShopDomain(' my-store.myshopify.com ')).toBe('my-store') + }) + + it('lowercases input', () => { + expect(normalizeShopDomain('MY-STORE.MYSHOPIFY.COM')).toBe('my-store') + }) + + it('handles full URL with mixed case and whitespace', () => { + expect(normalizeShopDomain(' HTTPS://MY-STORE.MYSHOPIFY.COM/admin/products ')).toBe('my-store') + }) +}) diff --git a/integrations/shopify-storefront/src/oauth/wizard.ts b/integrations/shopify-storefront/src/oauth/wizard.ts new file mode 100644 index 00000000000..0916255ed89 --- /dev/null +++ b/integrations/shopify-storefront/src/oauth/wizard.ts @@ -0,0 +1,235 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import * as sdk from '@botpress/sdk' +import { exchangeCodeForAccessToken, ShopifyAdminClient } from '../client' +import { STOREFRONT_ACCESS_TOKEN_CREATE, STOREFRONT_ACCESS_TOKENS_QUERY } from '../client/queries/admin' +import { verifyOAuthCallbackHmac } from './hmac' +import * as bp from '.botpress' + +type WizardHandler = oauthWizard.WizardStepHandler + +const SHOPIFY_OAUTH_SCOPES = [ + 'unauthenticated_read_product_listings', + 'unauthenticated_write_checkouts', + 'unauthenticated_read_checkouts', +].join(',') + +const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i +const STOREFRONT_TOKEN_TITLE = 'Botpress Storefront Access' + +export const oauthWizardHandler = async (props: bp.HandlerProps): Promise => { + 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 Storefront', + htmlOrMarkdownPageContents: + 'This wizard will connect your Shopify storefront 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, + storefrontAccessToken: undefined, + }) + + return responses.displayButtons({ + pageTitle: 'Confirm Shopify Store', + htmlOrMarkdownPageContents: `Is ${shopDomain}.myshopify.com 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 accessToken = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code }) + + const admin = new ShopifyAdminClient({ shopDomain: shopDomainFromCallback, accessToken }) + const storefrontAccessToken = await _provisionStorefrontToken(admin) + if (!storefrontAccessToken) { + return responses.endWizard({ + success: false, + errorMessage: + 'Failed to provision a Storefront API access token. Ensure the Shopify app has `unauthenticated_*` scopes enabled.', + }) + } + + await _patchCredentialsState(client, ctx, { + shopDomain: shopDomainFromCallback, + storefrontAccessToken, + }) + + 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 StorefrontAccessTokenNode = { id: string; title: string; accessToken: string } + +type StorefrontAccessTokensResponse = { + shop: { + storefrontAccessTokens: { + edges: Array<{ node: StorefrontAccessTokenNode }> + } + } +} + +type StorefrontAccessTokenCreateResponse = { + storefrontAccessTokenCreate: { + storefrontAccessToken: StorefrontAccessTokenNode | null + userErrors: Array<{ field: string[] | null; message: string }> + } +} + +// Idempotently ensure a Storefront Access Token exists for this shop. Reuses an existing +// Botpress-labeled token if present, otherwise creates a new one via the Admin API. +const _provisionStorefrontToken = async (admin: ShopifyAdminClient): Promise => { + const existing = await admin.query(STOREFRONT_ACCESS_TOKENS_QUERY) + const found = existing.shop.storefrontAccessTokens.edges.find((e) => e.node.title === STOREFRONT_TOKEN_TITLE) + if (found) { + return found.node.accessToken + } + + const result = await admin.query(STOREFRONT_ACCESS_TOKEN_CREATE, { + input: { title: STOREFRONT_TOKEN_TITLE }, + }) + + if (result.storefrontAccessTokenCreate.userErrors.length) { + return undefined + } + + return result.storefrontAccessTokenCreate.storefrontAccessToken?.accessToken +} + +type CredentialsPatch = { + shopDomain?: string + storefrontAccessToken?: 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 => { + try { + const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) + return (state?.payload as CredentialsPatch | undefined) ?? {} + } catch { + return {} + } +} diff --git a/integrations/shopify-storefront/src/setup.ts b/integrations/shopify-storefront/src/setup.ts new file mode 100644 index 00000000000..31246e283aa --- /dev/null +++ b/integrations/shopify-storefront/src/setup.ts @@ -0,0 +1,12 @@ +import * as bp from '.botpress' + +export const register: bp.IntegrationProps['register'] = async ({ logger }) => { + // Storefront credentials (shop domain + storefront access token) are persisted by the OAuth + // wizard (`src/oauth/wizard.ts`). No webhooks to subscribe — register is a no-op. + logger.forBot().info('Shopify Storefront integration registered.') +} + +export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => { + // No external resources to clean up; Storefront credentials are cleared with the integration state. + logger.forBot().info('Shopify Storefront integration unregistered.') +} diff --git a/integrations/shopify-storefront/src/transformers.test.ts b/integrations/shopify-storefront/src/transformers.test.ts new file mode 100644 index 00000000000..0d34b058607 --- /dev/null +++ b/integrations/shopify-storefront/src/transformers.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' +import { + transformStorefrontVariant, + transformStorefrontProduct, + transformCollection, + transformCartLine, + transformCart, +} from './transformers' + +describe('transformStorefrontVariant', () => { + it('maps price as {amount, currencyCode}', () => { + const result = transformStorefrontVariant({ + id: 'sv1', + title: 'Small', + availableForSale: true, + price: { amount: '19.99', currencyCode: 'CAD' }, + }) + expect(result).toEqual({ + id: 'sv1', + title: 'Small', + availableForSale: true, + price: { amount: '19.99', currencyCode: 'CAD' }, + }) + }) +}) + +describe('transformStorefrontProduct', () => { + const baseProduct = { + id: 'sp1', + title: 'Widget', + handle: 'widget', + description: 'A widget', + productType: 'Gadget', + vendor: 'Acme', + availableForSale: true, + onlineStoreUrl: 'https://shop.myshopify.com/products/widget', + priceRange: { + minVariantPrice: { amount: '10.00', currencyCode: 'USD' }, + maxVariantPrice: { amount: '20.00', currencyCode: 'USD' }, + }, + variants: { + edges: [ + { + node: { + id: 'sv1', + title: 'Default', + availableForSale: true, + price: { amount: '15.00', currencyCode: 'USD' }, + }, + }, + ], + }, + images: { edges: [{ node: { url: 'https://cdn.shopify.com/image.jpg', altText: 'Widget' } }] }, + } + + it('extracts imageUrl from first image edge', () => { + expect(transformStorefrontProduct(baseProduct, 'shop').imageUrl).toBe('https://cdn.shopify.com/image.jpg') + }) + + it('returns undefined imageUrl when images are empty', () => { + expect(transformStorefrontProduct({ ...baseProduct, images: { edges: [] } }, 'shop').imageUrl).toBeUndefined() + }) + + it('maps priceRange when present', () => { + const result = transformStorefrontProduct(baseProduct, 'shop') + expect(result.priceRange).toEqual({ + minVariantPrice: { amount: '10.00', currencyCode: 'USD' }, + maxVariantPrice: { amount: '20.00', currencyCode: 'USD' }, + }) + }) + + it('returns undefined priceRange when absent', () => { + const { priceRange: _, ...noRange } = baseProduct + expect(transformStorefrontProduct(noRange, 'shop').priceRange).toBeUndefined() + }) + + it('converts null optional fields to undefined', () => { + const result = transformStorefrontProduct( + { ...baseProduct, description: null, productType: null, vendor: null }, + 'shop' + ) + expect(result.description).toBeUndefined() + expect(result.productType).toBeUndefined() + expect(result.vendor).toBeUndefined() + }) + + it('uses onlineStoreUrl as storefrontUrl when present', () => { + const result = transformStorefrontProduct(baseProduct, 'shop') + expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget') + }) + + it('falls back to constructed URL when onlineStoreUrl is null', () => { + const result = transformStorefrontProduct({ ...baseProduct, onlineStoreUrl: null }, 'my-shop') + expect(result.storefrontUrl).toBe('https://my-shop.myshopify.com/products/widget') + }) +}) + +describe('transformCollection', () => { + it('maps all fields', () => { + const result = transformCollection({ + id: 'col1', + title: 'Summer', + handle: 'summer', + description: 'Summer collection', + image: { url: 'https://cdn.shopify.com/col.jpg', altText: 'Summer' }, + }) + expect(result).toEqual({ + id: 'col1', + title: 'Summer', + handle: 'summer', + description: 'Summer collection', + imageUrl: 'https://cdn.shopify.com/col.jpg', + }) + }) + + it('converts null image to undefined imageUrl', () => { + expect( + transformCollection({ id: 'col1', title: 'Summer', handle: 'summer', description: null, image: null }).imageUrl + ).toBeUndefined() + }) +}) + +describe('transformCartLine', () => { + it('maps merchandise fields', () => { + const result = transformCartLine({ + id: 'line1', + quantity: 2, + merchandise: { + id: 'merch1', + title: 'Small', + price: { amount: '25.00', currencyCode: 'USD' }, + product: { title: 'Widget' }, + }, + }) + expect(result).toEqual({ + lineId: 'line1', + quantity: 2, + merchandiseId: 'merch1', + title: 'Widget', + variantTitle: 'Small', + price: { amount: '25.00', currencyCode: 'USD' }, + }) + }) +}) + +describe('transformCart', () => { + const baseCart = { + id: 'cart1', + checkoutUrl: 'https://shop.myshopify.com/cart/checkout', + totalQuantity: 3, + cost: { + totalAmount: { amount: '75.00', currencyCode: 'USD' }, + subtotalAmount: { amount: '70.00', currencyCode: 'USD' }, + }, + lines: { + edges: [ + { + node: { + id: 'line1', + quantity: 3, + merchandise: { + id: 'merch1', + title: 'Default', + price: { amount: '25.00', currencyCode: 'USD' }, + product: { title: 'Widget' }, + }, + }, + }, + ], + }, + discountCodes: [{ code: 'SAVE10', applicable: true }], + } + + it('maps cart structure', () => { + const result = transformCart(baseCart) + expect(result.cartId).toBe('cart1') + expect(result.totalQuantity).toBe(3) + expect(result.totalAmount).toEqual({ amount: '75.00', currencyCode: 'USD' }) + expect(result.subtotalAmount).toEqual({ amount: '70.00', currencyCode: 'USD' }) + }) + + it('maps cart lines', () => { + expect(transformCart(baseCart).lines).toHaveLength(1) + expect(transformCart(baseCart).lines[0]!.title).toBe('Widget') + }) + + it('passes through discountCodes', () => { + expect(transformCart(baseCart).discountCodes).toEqual([{ code: 'SAVE10', applicable: true }]) + }) + + it('handles undefined discountCodes', () => { + const { discountCodes: _, ...noDiscount } = baseCart + expect(transformCart(noDiscount).discountCodes).toBeUndefined() + }) +}) diff --git a/integrations/shopify-storefront/src/transformers.ts b/integrations/shopify-storefront/src/transformers.ts new file mode 100644 index 00000000000..cf97032e73b --- /dev/null +++ b/integrations/shopify-storefront/src/transformers.ts @@ -0,0 +1,119 @@ +type MoneyV2 = { + amount: string + currencyCode: string +} + +type StorefrontVariantNode = { + id: string + title: string + availableForSale: boolean + price: MoneyV2 +} + +type StorefrontProductNode = { + id: string + title: string + handle: string + description: string | null + productType: string | null + vendor: string | null + availableForSale: boolean + onlineStoreUrl: string | null + priceRange?: { + minVariantPrice: MoneyV2 + maxVariantPrice: MoneyV2 + } + variants: { edges: Array<{ node: StorefrontVariantNode }> } + images: { edges: Array<{ node: { url: string; altText: string | null } }> } +} + +type CollectionNode = { + id: string + title: string + handle: string + description: string | null + image: { url: string; altText: string | null } | null +} + +type CartLineNode = { + id: string + quantity: number + merchandise: { + id: string + title: string + price: MoneyV2 + product: { title: string } + } +} + +export type CartNode = { + id: string + checkoutUrl: string + totalQuantity: number + cost: { + totalAmount: MoneyV2 + subtotalAmount: MoneyV2 + } + lines: { edges: Array<{ node: CartLineNode }> } + discountCodes?: Array<{ code: string; applicable: boolean }> +} + +export const transformStorefrontVariant = (node: StorefrontVariantNode) => ({ + id: node.id, + title: node.title, + availableForSale: node.availableForSale, + price: { amount: node.price.amount, currencyCode: node.price.currencyCode }, +}) + +export const transformStorefrontProduct = (node: StorefrontProductNode, shopDomain: string) => ({ + id: node.id, + title: node.title, + handle: node.handle, + description: node.description ?? undefined, + productType: node.productType ?? undefined, + vendor: node.vendor ?? undefined, + availableForSale: node.availableForSale, + priceRange: node.priceRange + ? { + minVariantPrice: { + amount: node.priceRange.minVariantPrice.amount, + currencyCode: node.priceRange.minVariantPrice.currencyCode, + }, + maxVariantPrice: { + amount: node.priceRange.maxVariantPrice.amount, + currencyCode: node.priceRange.maxVariantPrice.currencyCode, + }, + } + : undefined, + variants: node.variants.edges.map(({ node: v }) => transformStorefrontVariant(v)), + imageUrl: node.images.edges[0]?.node.url ?? undefined, + storefrontUrl: node.onlineStoreUrl ?? `https://${shopDomain}.myshopify.com/products/${node.handle}`, + onlineStoreUrl: node.onlineStoreUrl ?? undefined, +}) + +export const transformCollection = (node: CollectionNode) => ({ + id: node.id, + title: node.title, + handle: node.handle, + description: node.description ?? undefined, + imageUrl: node.image?.url ?? undefined, +}) + +export const transformCartLine = (node: CartLineNode) => ({ + lineId: node.id, + quantity: node.quantity, + merchandiseId: node.merchandise.id, + title: node.merchandise.product.title, + variantTitle: node.merchandise.title ?? undefined, + price: { amount: node.merchandise.price.amount, currencyCode: node.merchandise.price.currencyCode }, +}) + +export const transformCart = (cart: CartNode) => ({ + cartId: cart.id, + checkoutUrl: cart.checkoutUrl, + totalQuantity: cart.totalQuantity, + totalAmount: { amount: cart.cost.totalAmount.amount, currencyCode: cart.cost.totalAmount.currencyCode }, + subtotalAmount: { amount: cart.cost.subtotalAmount.amount, currencyCode: cart.cost.subtotalAmount.currencyCode }, + lines: cart.lines.edges.map(({ node }) => transformCartLine(node)), + discountCodes: cart.discountCodes, +}) diff --git a/integrations/shopify-storefront/tsconfig.json b/integrations/shopify-storefront/tsconfig.json new file mode 100644 index 00000000000..fc664427ddf --- /dev/null +++ b/integrations/shopify-storefront/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "baseUrl": ".", + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts", "*.json"] +} diff --git a/integrations/shopify-storefront/vitest.config.ts b/integrations/shopify-storefront/vitest.config.ts new file mode 100644 index 00000000000..15790f99dc3 --- /dev/null +++ b/integrations/shopify-storefront/vitest.config.ts @@ -0,0 +1,2 @@ +import config from '../../vitest.config' +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fd65c1f4ef..bb19021371d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1915,6 +1915,38 @@ importers: specifier: ^2.39.1 version: 2.39.1 + integrations/shopify-admin: + dependencies: + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + '@shopify/cli': + specifier: ~4.1.0 + version: 4.1.0 + + integrations/shopify-storefront: + dependencies: + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + '@shopify/cli': + specifier: ~4.1.0 + version: 4.1.0 + integrations/slack: dependencies: '@botpress/common': @@ -3688,6 +3720,68 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@ast-grep/napi-darwin-arm64@0.33.0': + resolution: {integrity: sha512-FsBQiBNGbqeU6z2sjFgnV6MXuBa0wYUb4PViMnqsKLeWiO7kRii5crmXLCtdTD2hufXTG6Rll8X46AkYOAwGGQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@ast-grep/napi-darwin-x64@0.33.0': + resolution: {integrity: sha512-rWo1wG7fc7K20z9ExIeN6U4QqjHhoQSpBDDnmxKTR0nIwPfyMq338sS4sWZomutxprcZDtWrekxH1lXjNvfuiA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@ast-grep/napi-linux-arm64-gnu@0.33.0': + resolution: {integrity: sha512-3ZnA2k57kxfvLg4s9+6rHaCx1FbWt0EF8fumJMf5nwevu7GbVOOhCkzAetZe80FBgZuIOSR4IS2QMj9ZHI0UdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-arm64-musl@0.33.0': + resolution: {integrity: sha512-oUGZgCaVCijFgvC+X52ttgoWUqgrIsSVJZgn+1VBY3n4mpzcoYAghFomSUbRTBUL2ebvZweA33Klqks4okY61w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-linux-x64-gnu@0.33.0': + resolution: {integrity: sha512-QTAkfxQSsOGRza0hnkeAgJDQqR00iDerRNq42dOGIzgF+Kse491By3UmBEMG4oCbv17yYcBBlknQkzKSKtigjw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-x64-musl@0.33.0': + resolution: {integrity: sha512-PW6bZO7MyQsBNZv0idI/Ah6ak66T8LqZ21wBGjtQp9NDGViOtkLeu+eJJGaZjMqUdidKHKgmMKXksZHl2m8ulQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-win32-arm64-msvc@0.33.0': + resolution: {integrity: sha512-ijmFQcFc32JOIQlSfnhDJpb3qFb2RhrRqfeY0EHHN1xRSGwZHfsHTSS66nKR2sREmxTIMgxXOtylKicbyyMVKA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@ast-grep/napi-win32-ia32-msvc@0.33.0': + resolution: {integrity: sha512-NNIb2VK3Z2BwKp0QJSw8gkhwOUp85SgTsxJ38p+wIUAA/KzAKCJOmyOaZ301qGHt4gL+jTHgTIvJJX+9eT/REg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@ast-grep/napi-win32-x64-msvc@0.33.0': + resolution: {integrity: sha512-gW7viQQjdPA1HoCkpCqoonC81TOwcpP828w/XqZFE/L6uhD8SF2usul8KNBQOiX3O7/fqYEOnbtWMCrwZIqG1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@ast-grep/napi@0.33.0': + resolution: {integrity: sha512-6heRMmomhSD0dkummRQ+R4xWXXmc41OaDPoPI49mKJXPyvwJPdPZUcQjXdIitOVL4uJV+qM2ZBucDPENDBSixw==} + engines: {node: '>= 10'} + '@aws-crypto/ie11-detection@3.0.0': resolution: {integrity: sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==} @@ -4481,6 +4575,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.19.12': resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} engines: {node: '>=12'} @@ -4505,6 +4605,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.19.12': resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} engines: {node: '>=12'} @@ -4529,6 +4635,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.19.12': resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} engines: {node: '>=12'} @@ -4553,6 +4665,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.19.12': resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} engines: {node: '>=12'} @@ -4577,6 +4695,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.19.12': resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} engines: {node: '>=12'} @@ -4601,6 +4725,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.19.12': resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} engines: {node: '>=12'} @@ -4625,6 +4755,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.19.12': resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} engines: {node: '>=12'} @@ -4649,6 +4785,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.19.12': resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} engines: {node: '>=12'} @@ -4673,6 +4815,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.19.12': resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} engines: {node: '>=12'} @@ -4697,6 +4845,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.19.12': resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} engines: {node: '>=12'} @@ -4721,6 +4875,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.19.12': resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} engines: {node: '>=12'} @@ -4745,6 +4905,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.19.12': resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} engines: {node: '>=12'} @@ -4769,6 +4935,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.19.12': resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} engines: {node: '>=12'} @@ -4793,6 +4965,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.19.12': resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} engines: {node: '>=12'} @@ -4817,6 +4995,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.19.12': resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} engines: {node: '>=12'} @@ -4841,6 +5025,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.19.12': resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} engines: {node: '>=12'} @@ -4865,12 +5055,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.10': resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.19.12': resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} engines: {node: '>=12'} @@ -4895,6 +5097,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.23.1': resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} engines: {node: '>=18'} @@ -4907,6 +5115,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.19.12': resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} engines: {node: '>=12'} @@ -4931,12 +5145,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.10': resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.19.12': resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} engines: {node: '>=12'} @@ -4961,6 +5187,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.19.12': resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} engines: {node: '>=12'} @@ -4985,6 +5217,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.19.12': resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} engines: {node: '>=12'} @@ -5009,6 +5247,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.19.12': resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} engines: {node: '>=12'} @@ -5033,6 +5277,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6394,6 +6644,12 @@ packages: resolution: {integrity: sha512-DKJA1LSUOEv4KOR828MzVuLh+drjeAgzyKgN063OEKmnirgjgRgNNS8wUgwpG0Tn2k6ANZGCwrdfzPeSBxshKg==} engines: {node: '>=8'} + '@shopify/cli@4.1.0': + resolution: {integrity: sha512-hGlR+R6fZD6ZIHYPWFcTgyQhtLFBbc+8RBiP5LkZwek8Wrjyh4Hga9vI1+BCIRENn82mHa0kYDrpP4c6vR2dsQ==} + engines: {node: '>=22.12.0'} + os: [darwin, linux, win32] + hasBin: true + '@sinclair/typebox@0.25.24': resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} @@ -7686,6 +7942,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + botbuilder-core@4.23.3: resolution: {integrity: sha512-48iW739I24piBH683b/Unvlu1fSzjB69ViOwZ0PbTkN2yW5cTvHJWlW7bXntO8GSqJfssgPaVthKfyaCW457ig==} @@ -8355,6 +8615,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -8553,6 +8816,9 @@ packages: resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} engines: {node: '>=0.10'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-iterator@2.0.3: resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} @@ -8591,6 +8857,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -9247,6 +9518,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -10441,6 +10716,10 @@ packages: engines: {node: '>= 16'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -11704,6 +11983,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rolldown-plugin-dts@0.25.2: resolution: {integrity: sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg==} engines: {node: ^22.18.0 || >=24.0.0} @@ -11823,6 +12106,9 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true @@ -11873,6 +12159,10 @@ packages: resolution: {integrity: sha512-hJWMZRwP75ocoBM+1/YaCsvS0j5MTPeBHJkS2/wruehl9xwtX30HlDF1Gt6UZ8HHHY8SJa2/IL+jo+JJCd59rA==} engines: {node: '>=0.4.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} @@ -12043,6 +12333,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sshpk@1.17.0: resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==} engines: {node: '>=0.10.0'} @@ -12557,6 +12850,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.15.1: resolution: {integrity: sha512-n+UXrN8i5ioo7kqT/nF8xsEzLaqFra7k32SEsSPwvXVGyAcRgV/FUQN/sgfptJTR1oRmmq7z4IXMFSM7im7C9A==} engines: {node: '>=10'} @@ -13250,6 +13547,45 @@ snapshots: lru-cache: 10.4.3 optional: true + '@ast-grep/napi-darwin-arm64@0.33.0': + optional: true + + '@ast-grep/napi-darwin-x64@0.33.0': + optional: true + + '@ast-grep/napi-linux-arm64-gnu@0.33.0': + optional: true + + '@ast-grep/napi-linux-arm64-musl@0.33.0': + optional: true + + '@ast-grep/napi-linux-x64-gnu@0.33.0': + optional: true + + '@ast-grep/napi-linux-x64-musl@0.33.0': + optional: true + + '@ast-grep/napi-win32-arm64-msvc@0.33.0': + optional: true + + '@ast-grep/napi-win32-ia32-msvc@0.33.0': + optional: true + + '@ast-grep/napi-win32-x64-msvc@0.33.0': + optional: true + + '@ast-grep/napi@0.33.0': + optionalDependencies: + '@ast-grep/napi-darwin-arm64': 0.33.0 + '@ast-grep/napi-darwin-x64': 0.33.0 + '@ast-grep/napi-linux-arm64-gnu': 0.33.0 + '@ast-grep/napi-linux-arm64-musl': 0.33.0 + '@ast-grep/napi-linux-x64-gnu': 0.33.0 + '@ast-grep/napi-linux-x64-musl': 0.33.0 + '@ast-grep/napi-win32-arm64-msvc': 0.33.0 + '@ast-grep/napi-win32-ia32-msvc': 0.33.0 + '@ast-grep/napi-win32-x64-msvc': 0.33.0 + '@aws-crypto/ie11-detection@3.0.0': dependencies: tslib: 1.14.1 @@ -14837,6 +15173,9 @@ snapshots: '@esbuild/aix-ppc64@0.25.10': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.19.12': optional: true @@ -14849,6 +15188,9 @@ snapshots: '@esbuild/android-arm64@0.25.10': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.19.12': optional: true @@ -14861,6 +15203,9 @@ snapshots: '@esbuild/android-arm@0.25.10': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.19.12': optional: true @@ -14873,6 +15218,9 @@ snapshots: '@esbuild/android-x64@0.25.10': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.19.12': optional: true @@ -14885,6 +15233,9 @@ snapshots: '@esbuild/darwin-arm64@0.25.10': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.19.12': optional: true @@ -14897,6 +15248,9 @@ snapshots: '@esbuild/darwin-x64@0.25.10': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.19.12': optional: true @@ -14909,6 +15263,9 @@ snapshots: '@esbuild/freebsd-arm64@0.25.10': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.19.12': optional: true @@ -14921,6 +15278,9 @@ snapshots: '@esbuild/freebsd-x64@0.25.10': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.19.12': optional: true @@ -14933,6 +15293,9 @@ snapshots: '@esbuild/linux-arm64@0.25.10': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.19.12': optional: true @@ -14945,6 +15308,9 @@ snapshots: '@esbuild/linux-arm@0.25.10': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.19.12': optional: true @@ -14957,6 +15323,9 @@ snapshots: '@esbuild/linux-ia32@0.25.10': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.19.12': optional: true @@ -14969,6 +15338,9 @@ snapshots: '@esbuild/linux-loong64@0.25.10': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.19.12': optional: true @@ -14981,6 +15353,9 @@ snapshots: '@esbuild/linux-mips64el@0.25.10': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.19.12': optional: true @@ -14993,6 +15368,9 @@ snapshots: '@esbuild/linux-ppc64@0.25.10': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.19.12': optional: true @@ -15005,6 +15383,9 @@ snapshots: '@esbuild/linux-riscv64@0.25.10': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.19.12': optional: true @@ -15017,6 +15398,9 @@ snapshots: '@esbuild/linux-s390x@0.25.10': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.19.12': optional: true @@ -15029,9 +15413,15 @@ snapshots: '@esbuild/linux-x64@0.25.10': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.10': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.19.12': optional: true @@ -15044,12 +15434,18 @@ snapshots: '@esbuild/netbsd-x64@0.25.10': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.23.1': optional: true '@esbuild/openbsd-arm64@0.25.10': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.19.12': optional: true @@ -15062,9 +15458,15 @@ snapshots: '@esbuild/openbsd-x64@0.25.10': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.10': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.19.12': optional: true @@ -15077,6 +15479,9 @@ snapshots: '@esbuild/sunos-x64@0.25.10': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.19.12': optional: true @@ -15089,6 +15494,9 @@ snapshots: '@esbuild/win32-arm64@0.25.10': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.19.12': optional: true @@ -15101,6 +15509,9 @@ snapshots: '@esbuild/win32-ia32@0.25.10': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.19.12': optional: true @@ -15113,6 +15524,9 @@ snapshots: '@esbuild/win32-x64@0.25.10': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.4.0(eslint@9.34.0(jiti@2.7.0))': dependencies: eslint: 9.34.0(jiti@2.7.0) @@ -16550,6 +16964,12 @@ snapshots: '@sentry/types': 7.53.1 tslib: 1.14.1 + '@shopify/cli@4.1.0': + dependencies: + '@ast-grep/napi': 0.33.0 + esbuild: 0.28.0 + global-agent: 3.0.0 + '@sinclair/typebox@0.25.24': {} '@sindresorhus/is@0.14.0': {} @@ -18388,6 +18808,8 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: {} + botbuilder-core@4.23.3: dependencies: botbuilder-dialogs-adaptive-runtime-core: 4.23.3-preview @@ -19095,6 +19517,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node@2.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -19394,6 +19818,8 @@ snapshots: esniff: 2.0.1 next-tick: 1.1.0 + es6-error@4.1.1: {} + es6-iterator@2.0.3: dependencies: d: 1.0.2 @@ -19528,6 +19954,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.10 '@esbuild/win32-x64': 0.25.10 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -20317,6 +20772,15 @@ snapshots: minipass: 4.2.8 path-scurry: 1.10.2 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.1 + serialize-error: 7.0.1 + globals@11.12.0: {} globals@14.0.0: {} @@ -21832,6 +22296,10 @@ snapshots: marked@7.0.4: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} md-to-react-email@5.0.2(react@18.3.1): @@ -23422,6 +23890,15 @@ snapshots: dependencies: glob: 7.2.3 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + rolldown-plugin-dts@0.25.2(rolldown@1.0.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.6 @@ -23593,6 +24070,8 @@ snapshots: dependencies: parseley: 0.12.1 + semver-compare@1.0.0: {} + semver@5.7.1: {} semver@6.3.1: {} @@ -23650,6 +24129,10 @@ snapshots: sequin@0.1.1: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + serve-static@1.16.2: dependencies: encodeurl: 2.0.0 @@ -23837,6 +24320,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + sshpk@1.17.0: dependencies: asn1: 0.2.6 @@ -24428,6 +24913,8 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.13.1: {} + type-fest@0.15.1: {} type-fest@0.20.2: {} From 450695a0bc901781524607d15ad046cb41d1e3c1 Mon Sep 17 00:00:00 2001 From: Mathieu Faucher <99497774+Math-Fauch@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:29:21 -0400 Subject: [PATCH 3/3] fix(integrations): use pnpm for shopify cli (#15272) --- .github/actions/deploy-integrations/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/deploy-integrations/action.yml b/.github/actions/deploy-integrations/action.yml index c92f7e668a6..d6c8b069d71 100644 --- a/.github/actions/deploy-integrations/action.yml +++ b/.github/actions/deploy-integrations/action.yml @@ -158,7 +158,7 @@ runs: echo "::warning::Found $manifest_file but no Shopify automation token for $integration - skipping manifest deploy" else echo -e "\nDeploying Shopify app manifest ($INPUT_ENVIRONMENT): ### $integration ###\n" - shopify_deploy_command="shopify app deploy --config $INPUT_ENVIRONMENT --allow-updates --source-control-url $COMMIT_URL" + shopify_deploy_command="pnpm shopify app deploy --config $INPUT_ENVIRONMENT --allow-updates --source-control-url $COMMIT_URL" SHOPIFY_APP_AUTOMATION_TOKEN="$shopify_token" \ pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$shopify_deploy_command" fi