chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -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']
@@ -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' },
}
@@ -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'),
})
@@ -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']
+34
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 108.44 122.88" style="enable-background:new 0 0 108.44 122.88" xml:space="preserve">
<style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#95BF47;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#5E8E3E;} .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}</style>
<g>
<path class="st0" d="M94.98,23.66c-0.09-0.62-0.63-0.96-1.08-1c-0.45-0.04-9.19-0.17-9.19-0.17s-7.32-7.1-8.04-7.83 c-0.72-0.72-2.13-0.5-2.68-0.34c-0.01,0-1.37,0.43-3.68,1.14c-0.38-1.25-0.95-2.78-1.76-4.32c-2.6-4.97-6.42-7.6-11.03-7.61 c-0.01,0-0.01,0-0.02,0c-0.32,0-0.64,0.03-0.96,0.06c-0.14-0.16-0.27-0.32-0.42-0.48c-2.01-2.15-4.58-3.19-7.67-3.1 c-5.95,0.17-11.88,4.47-16.69,12.11c-3.38,5.37-5.96,12.12-6.69,17.35c-6.83,2.12-11.61,3.6-11.72,3.63 c-3.45,1.08-3.56,1.19-4.01,4.44C9.03,39.99,0,109.8,0,109.8l75.65,13.08l32.79-8.15C108.44,114.73,95.06,24.28,94.98,23.66 L94.98,23.66z M66.52,16.63c-1.74,0.54-3.72,1.15-5.87,1.82c-0.04-3.01-0.4-7.21-1.81-10.83C63.36,8.47,65.58,13.58,66.52,16.63 L66.52,16.63z M56.69,19.68c-3.96,1.23-8.29,2.57-12.63,3.91c1.22-4.67,3.54-9.33,6.38-12.38c1.06-1.14,2.54-2.4,4.29-3.12 C56.38,11.52,56.73,16.39,56.69,19.68L56.69,19.68z M48.58,3.97c1.4-0.03,2.57,0.28,3.58,0.94C50.55,5.74,49,6.94,47.54,8.5 c-3.78,4.06-6.68,10.35-7.83,16.43c-3.6,1.11-7.13,2.21-10.37,3.21C31.38,18.58,39.4,4.23,48.58,3.97L48.58,3.97z"/>
<path class="st1" d="M93.9,22.66c-0.45-0.04-9.19-0.17-9.19-0.17s-7.32-7.1-8.04-7.83c-0.27-0.27-0.63-0.41-1.02-0.47l0,108.68 l32.78-8.15c0,0-13.38-90.44-13.46-91.06C94.9,23.04,94.35,22.7,93.9,22.66L93.9,22.66z"/>
<path class="st2" d="M57.48,39.52l-3.81,14.25c0,0-4.25-1.93-9.28-1.62c-7.38,0.47-7.46,5.12-7.39,6.29 c0.4,6.37,17.16,7.76,18.11,22.69c0.74,11.74-6.23,19.77-16.27,20.41c-12.05,0.76-18.69-6.35-18.69-6.35l2.55-10.86 c0,0,6.68,5.04,12.02,4.7c3.49-0.22,4.74-3.06,4.61-5.07c-0.52-8.31-14.18-7.82-15.04-21.48c-0.73-11.49,6.82-23.14,23.48-24.19 C54.2,37.88,57.48,39.52,57.48,39.52L57.48,39.52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,20 @@
import { IntegrationDefinition } from '@botpress/sdk'
import { actions, states, configuration, secrets } from './definitions'
export default new IntegrationDefinition({
name: 'shopify-storefront',
version: '0.1.3',
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,
attributes: {
category: 'E-commerce & Payments',
repo: 'botpress',
},
})
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
@@ -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"
}
}
@@ -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"
@@ -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"
@@ -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"
@@ -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<CartLinesAddResponse>(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) }
}
@@ -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<CartDiscountCodesUpdateResponse>(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) }
}
@@ -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<string, unknown> = {
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<CartCreateResponse>(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) }
}
@@ -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<CartQueryResponse>(CART_QUERY, { id: input.cartId })
if (!data.cart) {
throw new RuntimeError(`Cart not found: ${input.cartId}`)
}
return { cart: transformCart(data.cart) }
}
@@ -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<CollectionResponse>(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,
},
}
}
@@ -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<ProductResponse>(query, variables)
if (!data.product) {
throw new RuntimeError(`Product not found: ${input.handle ?? input.productId}`)
}
return { product: transformStorefrontProduct(data.product, storefront.shopDomain) }
}
@@ -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']
@@ -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<CollectionsResponse>(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,
},
}
}
@@ -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<SearchProductsResponse>(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,
},
}
}
@@ -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/)
})
})
@@ -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<T = unknown>(graphql: string, variables: Record<string, unknown> = {}): Promise<T> {
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<string> => {
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
}
@@ -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
}
}
}
`
@@ -0,0 +1 @@
export const SHOPIFY_API_VERSION = '2026-04'
@@ -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
}
}
}
`
@@ -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<StorefrontClient> {
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<T = unknown>(graphql: string, variables: Record<string, unknown> = {}): Promise<T> {
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
}
}
@@ -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<string, string> = {}
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: '' })
})
})
@@ -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: '' }
}
}
@@ -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,
})
@@ -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<string, string>, 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)
})
})
@@ -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)
}
@@ -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')
})
})
@@ -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<bp.HandlerProps>
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<sdk.Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'get-shop', handler: _getShopHandler })
.addStep({ id: 'validate-shop', handler: _validateShopHandler })
.addStep({ id: 'authorize', handler: _authorizeHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'end', handler: _endHandler })
.build()
return await wizard.handleRequest()
}
const _startHandler: WizardHandler = ({ responses }) =>
responses.displayButtons({
pageTitle: 'Connect Shopify 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 <strong>${shopDomain}.myshopify.com</strong> your Shopify store?`,
buttons: [
{ action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' },
{ action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' },
],
})
}
const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => {
const { shopDomain } = await _getCredentialsState(client, ctx)
if (!shopDomain) {
throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard')
}
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString()
const authorizeUrl =
`https://${shopDomain}.myshopify.com/admin/oauth/authorize` +
`?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` +
`&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${encodeURIComponent(ctx.webhookId)}`
return responses.redirectToExternalUrl(authorizeUrl)
}
const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => {
try {
const state = query.get('state')
if (state !== ctx.webhookId) {
return responses.endWizard({
success: false,
errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.',
})
}
if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
return responses.endWizard({
success: false,
errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.',
})
}
const code = query.get('code')
const shopParam = query.get('shop')
if (!code || !shopParam) {
return responses.endWizard({
success: false,
errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.',
})
}
const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase()
const stored = await _getCredentialsState(client, ctx)
if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) {
return responses.endWizard({
success: false,
errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`,
})
}
const 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<string | undefined> => {
const existing = await admin.query<StorefrontAccessTokensResponse>(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<StorefrontAccessTokenCreateResponse>(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<CredentialsPatch> => {
try {
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
return (state?.payload as CredentialsPatch | undefined) ?? {}
} catch {
return {}
}
}
@@ -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.')
}
@@ -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()
})
})
@@ -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,
})
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config