@shopify/hydrogen 2026.10.0-preview.2 → 2026.10.0-preview.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/dist/cli/index.mjs +578 -85
- package/dist/client/client.mjs +2 -2
- package/dist/client/errors.d.mts +19 -19
- package/dist/client/errors.mjs +19 -16
- package/dist/client/errors.mjs.map +1 -1
- package/dist/client/types.d.mts +91 -10
- package/dist/core/analytics/cart-tracker.d.mts +14 -0
- package/dist/core/analytics/cart-tracker.mjs +15 -1
- package/dist/core/analytics/cart-tracker.mjs.map +1 -1
- package/dist/core/analytics/custom-consent.mjs +12 -0
- package/dist/core/analytics/custom-consent.mjs.map +1 -0
- package/dist/core/analytics/events.d.mts +7 -0
- package/dist/core/analytics/events.mjs +7 -0
- package/dist/core/analytics/events.mjs.map +1 -1
- package/dist/core/analytics/index.d.mts +1 -1
- package/dist/core/analytics/types.d.mts +152 -16
- package/dist/core/cart/actions.d.mts +47 -3
- package/dist/core/cart/actions.mjs +29 -0
- package/dist/core/cart/actions.mjs.map +1 -1
- package/dist/core/cart/attach-quantity-input.d.mts +26 -0
- package/dist/core/cart/attach-quantity-input.mjs +26 -0
- package/dist/core/cart/attach-quantity-input.mjs.map +1 -1
- package/dist/core/cart/cart.d.mts +106 -6
- package/dist/core/cart/cart.mjs +57 -6
- package/dist/core/cart/cart.mjs.map +1 -1
- package/dist/core/cart/cookie.d.mts +15 -0
- package/dist/core/cart/cookie.mjs +15 -0
- package/dist/core/cart/cookie.mjs.map +1 -1
- package/dist/core/cart/form.d.mts +61 -0
- package/dist/core/cart/form.mjs +18 -0
- package/dist/core/cart/form.mjs.map +1 -1
- package/dist/core/cart/get-cart.d.mts +43 -2
- package/dist/core/cart/get-cart.mjs +33 -0
- package/dist/core/cart/get-cart.mjs.map +1 -1
- package/dist/core/cart/index.d.mts +1 -1
- package/dist/core/cart/quantity.d.mts +17 -0
- package/dist/core/cart/quantity.mjs +17 -0
- package/dist/core/cart/quantity.mjs.map +1 -1
- package/dist/core/cart/queries.d.mts +402 -369
- package/dist/core/cart/queries.mjs +2 -1
- package/dist/core/cart/queries.mjs.map +1 -1
- package/dist/core/cart/server-handlers.d.mts +69 -0
- package/dist/core/cart/server-handlers.mjs +2 -10
- package/dist/core/cart/server-handlers.mjs.map +1 -1
- package/dist/core/cart/state.d.mts +133 -0
- package/dist/core/cart/state.mjs +8 -0
- package/dist/core/cart/state.mjs.map +1 -1
- package/dist/core/collection/collection.d.mts +18 -2
- package/dist/core/collection/collection.mjs +11 -0
- package/dist/core/collection/collection.mjs.map +1 -1
- package/dist/core/collection/index.d.mts +1 -1
- package/dist/core/collection/reconciler.d.mts +13 -4
- package/dist/core/collection/reconciler.mjs.map +1 -1
- package/dist/core/collection/state.d.mts +7 -2
- package/dist/core/collection/state.mjs.map +1 -1
- package/dist/core/collection/url.d.mts +3 -0
- package/dist/core/collection/url.mjs.map +1 -1
- package/dist/core/constants.d.mts +1 -1
- package/dist/core/constants.mjs +2 -2
- package/dist/core/constants.mjs.map +1 -1
- package/dist/core/headers.mjs +2 -2
- package/dist/core/headers.mjs.map +1 -1
- package/dist/core/index.d.mts +5 -5
- package/dist/core/logging/logging.mjs.map +1 -1
- package/dist/core/money/format.mjs +6 -3
- package/dist/core/money/format.mjs.map +1 -1
- package/dist/core/observable.mjs +0 -3
- package/dist/core/observable.mjs.map +1 -1
- package/dist/core/predictive-search/form.d.mts +1 -0
- package/dist/core/predictive-search/form.mjs +1 -0
- package/dist/core/predictive-search/form.mjs.map +1 -1
- package/dist/core/predictive-search/index.d.mts +1 -1
- package/dist/core/predictive-search/queries.d.mts +40 -4
- package/dist/core/predictive-search/queries.mjs +7 -1
- package/dist/core/predictive-search/queries.mjs.map +1 -1
- package/dist/core/predictive-search/search.d.mts +43 -10
- package/dist/core/predictive-search/search.mjs +34 -0
- package/dist/core/predictive-search/search.mjs.map +1 -1
- package/dist/core/predictive-search/server-handlers.d.mts +31 -5
- package/dist/core/predictive-search/server-handlers.mjs +5 -3
- package/dist/core/predictive-search/server-handlers.mjs.map +1 -1
- package/dist/core/predictive-search/store.d.mts +3 -1
- package/dist/core/predictive-search/store.mjs.map +1 -1
- package/dist/core/predictive-search/url.d.mts +50 -8
- package/dist/core/predictive-search/url.mjs +9 -0
- package/dist/core/predictive-search/url.mjs.map +1 -1
- package/dist/core/product/form.d.mts +21 -2
- package/dist/core/product/form.mjs +13 -0
- package/dist/core/product/form.mjs.map +1 -1
- package/dist/core/product/index.d.mts +1 -1
- package/dist/core/product/options.d.mts +2 -2
- package/dist/core/product/options.mjs +2 -2
- package/dist/core/product/options.mjs.map +1 -1
- package/dist/core/product/product-form.d.mts +33 -1
- package/dist/core/product/product-form.mjs +30 -0
- package/dist/core/product/product-form.mjs.map +1 -1
- package/dist/core/product/state.d.mts +41 -0
- package/dist/core/product/url.d.mts +6 -0
- package/dist/core/product/url.mjs.map +1 -1
- package/dist/core/request-context.d.mts +8 -5
- package/dist/core/request-context.mjs +17 -39
- package/dist/core/request-context.mjs.map +1 -1
- package/dist/core/request-routing/handle-shopify-routes.mjs +4 -1
- package/dist/core/request-routing/handle-shopify-routes.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/ajax-api.mjs +1 -1
- package/dist/core/request-routing/interceptors/api-proxy.mjs +1 -1
- package/dist/core/request-routing/interceptors/checkout.mjs +22 -6
- package/dist/core/request-routing/interceptors/checkout.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/mcp-proxy.mjs +1 -1
- package/dist/core/request-routing/interceptors/proxy.mjs +36 -10
- package/dist/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/query-param-redirect.mjs +5 -11
- package/dist/core/request-routing/interceptors/query-param-redirect.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/sfapi-proxy.mjs +19 -1
- package/dist/core/request-routing/interceptors/sfapi-proxy.mjs.map +1 -1
- package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
- package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
- package/dist/core/request-routing/interceptors/well-known.mjs +1 -1
- package/dist/core/shopify-scripts/analytics.mjs +2 -2
- package/dist/core/shopify-scripts/analytics.mjs.map +1 -1
- package/dist/core/shopify-scripts/constants.mjs +4 -2
- package/dist/core/shopify-scripts/constants.mjs.map +1 -1
- package/dist/core/shopify-scripts/global.mjs +9 -4
- package/dist/core/shopify-scripts/global.mjs.map +1 -1
- package/dist/core/shopify-scripts/index.d.mts +1 -0
- package/dist/core/shopify-scripts/index.mjs +14 -12
- package/dist/core/shopify-scripts/index.mjs.map +1 -1
- package/dist/core/shopify-scripts/initialize.d.mts +1 -0
- package/dist/core/shopify-scripts/initialize.mjs +10 -3
- package/dist/core/shopify-scripts/initialize.mjs.map +1 -1
- package/dist/core/shopify-scripts/types.d.mts +6 -0
- package/dist/core/url.mjs +21 -2
- package/dist/core/url.mjs.map +1 -1
- package/dist/customer-account/client.d.mts +128 -4
- package/dist/customer-account/client.mjs +27 -1
- package/dist/customer-account/client.mjs.map +1 -1
- package/dist/customer-account/errors.d.mts +46 -0
- package/dist/customer-account/errors.mjs +46 -0
- package/dist/customer-account/errors.mjs.map +1 -1
- package/dist/customer-account/graphql.d.mts +68 -0
- package/dist/customer-account/graphql.mjs +47 -0
- package/dist/customer-account/graphql.mjs.map +1 -1
- package/dist/customer-account/session.d.mts +114 -23
- package/dist/customer-account/session.mjs +27 -12
- package/dist/customer-account/session.mjs.map +1 -1
- package/dist/customer-account/type-resolver.d.mts +23 -0
- package/dist/customer-account-api-types.d.ts +570 -374
- package/dist/customer-account.schema.json +2981 -1527
- package/dist/development/client/client.mjs +2 -2
- package/dist/development/client/errors.d.mts +19 -19
- package/dist/development/client/errors.mjs +20 -20
- package/dist/development/client/errors.mjs.map +1 -1
- package/dist/development/client/types.d.mts +91 -10
- package/dist/development/core/analytics/cart-tracker.d.mts +14 -0
- package/dist/development/core/analytics/cart-tracker.mjs +15 -1
- package/dist/development/core/analytics/cart-tracker.mjs.map +1 -1
- package/dist/development/core/analytics/custom-consent.mjs +12 -0
- package/dist/development/core/analytics/custom-consent.mjs.map +1 -0
- package/dist/development/core/analytics/events.d.mts +7 -0
- package/dist/development/core/analytics/events.mjs +7 -0
- package/dist/development/core/analytics/events.mjs.map +1 -1
- package/dist/development/core/analytics/index.d.mts +1 -1
- package/dist/development/core/analytics/types.d.mts +152 -16
- package/dist/development/core/cart/actions.d.mts +47 -3
- package/dist/development/core/cart/actions.mjs +29 -0
- package/dist/development/core/cart/actions.mjs.map +1 -1
- package/dist/development/core/cart/attach-quantity-input.d.mts +26 -0
- package/dist/development/core/cart/attach-quantity-input.mjs +26 -0
- package/dist/development/core/cart/attach-quantity-input.mjs.map +1 -1
- package/dist/development/core/cart/cart.d.mts +106 -6
- package/dist/development/core/cart/cart.mjs +57 -6
- package/dist/development/core/cart/cart.mjs.map +1 -1
- package/dist/development/core/cart/cookie.d.mts +15 -0
- package/dist/development/core/cart/cookie.mjs +15 -0
- package/dist/development/core/cart/cookie.mjs.map +1 -1
- package/dist/development/core/cart/form.d.mts +61 -0
- package/dist/development/core/cart/form.mjs +18 -0
- package/dist/development/core/cart/form.mjs.map +1 -1
- package/dist/development/core/cart/get-cart.d.mts +43 -2
- package/dist/development/core/cart/get-cart.mjs +33 -0
- package/dist/development/core/cart/get-cart.mjs.map +1 -1
- package/dist/development/core/cart/index.d.mts +1 -1
- package/dist/development/core/cart/quantity.d.mts +17 -0
- package/dist/development/core/cart/quantity.mjs +17 -0
- package/dist/development/core/cart/quantity.mjs.map +1 -1
- package/dist/development/core/cart/queries.d.mts +345 -312
- package/dist/development/core/cart/queries.mjs +2 -1
- package/dist/development/core/cart/queries.mjs.map +1 -1
- package/dist/development/core/cart/server-handlers.d.mts +69 -0
- package/dist/development/core/cart/server-handlers.mjs +2 -10
- package/dist/development/core/cart/server-handlers.mjs.map +1 -1
- package/dist/development/core/cart/state.d.mts +133 -0
- package/dist/development/core/cart/state.mjs +8 -0
- package/dist/development/core/cart/state.mjs.map +1 -1
- package/dist/development/core/collection/collection.d.mts +18 -2
- package/dist/development/core/collection/collection.mjs +11 -0
- package/dist/development/core/collection/collection.mjs.map +1 -1
- package/dist/development/core/collection/index.d.mts +1 -1
- package/dist/development/core/collection/reconciler.d.mts +13 -4
- package/dist/development/core/collection/reconciler.mjs.map +1 -1
- package/dist/development/core/collection/state.d.mts +7 -2
- package/dist/development/core/collection/state.mjs.map +1 -1
- package/dist/development/core/collection/url.d.mts +3 -0
- package/dist/development/core/collection/url.mjs.map +1 -1
- package/dist/development/core/constants.mjs +1 -1
- package/dist/development/core/constants.mjs.map +1 -1
- package/dist/development/core/development.d.mts +5 -5
- package/dist/development/core/headers.mjs +2 -2
- package/dist/development/core/headers.mjs.map +1 -1
- package/dist/development/core/index.d.mts +4 -4
- package/dist/development/core/logging/logging.mjs.map +1 -1
- package/dist/development/core/money/format.mjs +6 -3
- package/dist/development/core/money/format.mjs.map +1 -1
- package/dist/development/core/observable.mjs +0 -3
- package/dist/development/core/observable.mjs.map +1 -1
- package/dist/development/core/predictive-search/form.d.mts +1 -0
- package/dist/development/core/predictive-search/form.mjs +1 -0
- package/dist/development/core/predictive-search/form.mjs.map +1 -1
- package/dist/development/core/predictive-search/index.d.mts +1 -1
- package/dist/development/core/predictive-search/queries.d.mts +38 -2
- package/dist/development/core/predictive-search/queries.mjs +7 -1
- package/dist/development/core/predictive-search/queries.mjs.map +1 -1
- package/dist/development/core/predictive-search/search.d.mts +43 -10
- package/dist/development/core/predictive-search/search.mjs +34 -0
- package/dist/development/core/predictive-search/search.mjs.map +1 -1
- package/dist/development/core/predictive-search/server-handlers.d.mts +31 -5
- package/dist/development/core/predictive-search/server-handlers.mjs +5 -3
- package/dist/development/core/predictive-search/server-handlers.mjs.map +1 -1
- package/dist/development/core/predictive-search/store.d.mts +3 -1
- package/dist/development/core/predictive-search/store.mjs.map +1 -1
- package/dist/development/core/predictive-search/url.d.mts +50 -8
- package/dist/development/core/predictive-search/url.mjs +9 -0
- package/dist/development/core/predictive-search/url.mjs.map +1 -1
- package/dist/development/core/product/form.d.mts +21 -2
- package/dist/development/core/product/form.mjs +13 -0
- package/dist/development/core/product/form.mjs.map +1 -1
- package/dist/development/core/product/index.d.mts +1 -1
- package/dist/development/core/product/options.d.mts +2 -2
- package/dist/development/core/product/options.mjs +2 -2
- package/dist/development/core/product/options.mjs.map +1 -1
- package/dist/development/core/product/product-form.d.mts +33 -1
- package/dist/development/core/product/product-form.mjs +30 -0
- package/dist/development/core/product/product-form.mjs.map +1 -1
- package/dist/development/core/product/state.d.mts +41 -0
- package/dist/development/core/product/url.d.mts +6 -0
- package/dist/development/core/product/url.mjs.map +1 -1
- package/dist/development/core/request-context.d.mts +8 -5
- package/dist/development/core/request-context.mjs +17 -39
- package/dist/development/core/request-context.mjs.map +1 -1
- package/dist/development/core/request-routing/handle-shopify-routes.mjs +4 -1
- package/dist/development/core/request-routing/handle-shopify-routes.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/ajax-api.mjs +1 -1
- package/dist/development/core/request-routing/interceptors/api-proxy.mjs +1 -1
- package/dist/development/core/request-routing/interceptors/checkout.mjs +22 -6
- package/dist/development/core/request-routing/interceptors/checkout.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/mcp-proxy.mjs +1 -1
- package/dist/development/core/request-routing/interceptors/proxy.mjs +36 -10
- package/dist/development/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/query-param-redirect.mjs +5 -11
- package/dist/development/core/request-routing/interceptors/query-param-redirect.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/sfapi-proxy.mjs +19 -1
- package/dist/development/core/request-routing/interceptors/sfapi-proxy.mjs.map +1 -1
- package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
- package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
- package/dist/development/core/request-routing/interceptors/well-known.mjs +1 -1
- package/dist/development/core/shopify-scripts/analytics.mjs +2 -2
- package/dist/development/core/shopify-scripts/analytics.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/constants.mjs +4 -2
- package/dist/development/core/shopify-scripts/constants.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/global.mjs +9 -4
- package/dist/development/core/shopify-scripts/global.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/index.d.mts +1 -0
- package/dist/development/core/shopify-scripts/index.mjs +14 -12
- package/dist/development/core/shopify-scripts/index.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/initialize.d.mts +1 -0
- package/dist/development/core/shopify-scripts/initialize.mjs +10 -3
- package/dist/development/core/shopify-scripts/initialize.mjs.map +1 -1
- package/dist/development/core/shopify-scripts/types.d.mts +6 -0
- package/dist/development/core/url.mjs +21 -2
- package/dist/development/core/url.mjs.map +1 -1
- package/dist/development/customer-account/session.d.mts +69 -13
- package/dist/development/customer-account/session.mjs +4 -0
- package/dist/development/customer-account/session.mjs.map +1 -1
- package/dist/development/globals.d.mts +6 -5
- package/dist/development/graphql/generated/graphql-env.d.mts +459 -16
- package/dist/development/graphql/generated/storefront-api-types.d.mts +1 -1
- package/dist/development/graphql/graphql.d.mts +33 -0
- package/dist/development/graphql/graphql.mjs +30 -0
- package/dist/development/graphql/graphql.mjs.map +1 -1
- package/dist/development/graphql/type-resolver.d.mts +14 -0
- package/dist/development/react/cart.d.mts +85 -14
- package/dist/development/react/cart.mjs +123 -3
- package/dist/development/react/cart.mjs.map +1 -1
- package/dist/development/react/collection.d.mts +2 -4
- package/dist/development/react/collection.mjs.map +1 -1
- package/dist/development/react/index.d.mts +8 -6
- package/dist/development/react/index.mjs +2 -2
- package/dist/development/react/predictive-search.d.mts +68 -7
- package/dist/development/react/predictive-search.mjs +54 -12
- package/dist/development/react/predictive-search.mjs.map +1 -1
- package/dist/development/react/product.d.mts +1 -0
- package/dist/development/react/product.mjs +5 -2
- package/dist/development/react/product.mjs.map +1 -1
- package/dist/development/react/shopify-scripts.mjs +1 -0
- package/dist/development/react/shopify-scripts.mjs.map +1 -1
- package/dist/development/vue/cart.d.mts +85 -6
- package/dist/development/vue/cart.mjs +95 -0
- package/dist/development/vue/cart.mjs.map +1 -1
- package/dist/development/vue/collection.d.mts +83 -5
- package/dist/development/vue/collection.mjs +52 -0
- package/dist/development/vue/collection.mjs.map +1 -1
- package/dist/development/vue/index.d.mts +6 -4
- package/dist/development/vue/predictive-search.d.mts +53 -6
- package/dist/development/vue/predictive-search.mjs +33 -0
- package/dist/development/vue/predictive-search.mjs.map +1 -1
- package/dist/development/vue/product.d.mts +6 -2
- package/dist/development/vue/product.mjs.map +1 -1
- package/dist/development/vue/shopify-scripts.d.mts +30 -3
- package/dist/development/vue/shopify-scripts.mjs +6 -0
- package/dist/development/vue/shopify-scripts.mjs.map +1 -1
- package/dist/globals.d.mts +6 -5
- package/dist/graphql/generated/customer-account-graphql-env.d.mts +1717 -1090
- package/dist/graphql/generated/graphql-env.d.mts +459 -16
- package/dist/graphql/generated/storefront-api-types.d.mts +1 -1
- package/dist/graphql/graphql.d.mts +33 -0
- package/dist/graphql/graphql.mjs +30 -0
- package/dist/graphql/graphql.mjs.map +1 -1
- package/dist/graphql/type-resolver.d.mts +14 -0
- package/dist/react/cart.d.mts +85 -14
- package/dist/react/cart.mjs +123 -3
- package/dist/react/cart.mjs.map +1 -1
- package/dist/react/collection.d.mts +2 -4
- package/dist/react/collection.mjs.map +1 -1
- package/dist/react/index.d.mts +8 -6
- package/dist/react/index.mjs +2 -2
- package/dist/react/predictive-search.d.mts +68 -7
- package/dist/react/predictive-search.mjs +54 -12
- package/dist/react/predictive-search.mjs.map +1 -1
- package/dist/react/product.d.mts +1 -0
- package/dist/react/product.mjs +5 -2
- package/dist/react/product.mjs.map +1 -1
- package/dist/react/shopify-scripts.mjs +1 -0
- package/dist/react/shopify-scripts.mjs.map +1 -1
- package/dist/storefront-api-types.d.ts +165 -20
- package/dist/storefront.schema.json +782 -57
- package/dist/vite/customer-account.mjs +191 -0
- package/dist/vite/customer-account.mjs.map +1 -0
- package/dist/vite/local-https.mjs +13 -29
- package/dist/vite/local-https.mjs.map +1 -1
- package/dist/vite/mkcert.mjs +1 -1
- package/dist/vue/cart.d.mts +85 -6
- package/dist/vue/cart.mjs +95 -0
- package/dist/vue/cart.mjs.map +1 -1
- package/dist/vue/collection.d.mts +83 -5
- package/dist/vue/collection.mjs +52 -0
- package/dist/vue/collection.mjs.map +1 -1
- package/dist/vue/index.d.mts +6 -4
- package/dist/vue/predictive-search.d.mts +53 -6
- package/dist/vue/predictive-search.mjs +33 -0
- package/dist/vue/predictive-search.mjs.map +1 -1
- package/dist/vue/product.d.mts +6 -2
- package/dist/vue/product.mjs.map +1 -1
- package/dist/vue/shopify-scripts.d.mts +31 -4
- package/dist/vue/shopify-scripts.mjs +6 -0
- package/dist/vue/shopify-scripts.mjs.map +1 -1
- package/package.json +5 -2
- package/skills/hydrogen-analytics/SKILL.md +6 -5
- package/skills/hydrogen-analytics/references/custom-consent.md +52 -0
- package/skills/hydrogen-analytics/references/react.md +1 -1
- package/skills/hydrogen-cart-metafields/SKILL.md +133 -0
- package/skills/hydrogen-cart-ui/SKILL.md +2 -1
- package/skills/hydrogen-customer-account/SKILL.md +3 -3
- package/skills/hydrogen-local-https/SKILL.md +22 -7
- package/skills/hydrogen-request-handlers/SKILL.md +5 -5
- package/skills/hydrogen-request-handlers/references/nuxt.md +0 -6
- package/skills/hydrogen-setup/references/analytics.md +24 -23
- package/skills/hydrogen-setup/steps/1-inspect-app.md +1 -1
- package/skills/hydrogen-setup/steps/11-verify.md +1 -0
- package/skills/hydrogen-setup/steps/2-scaffold.md +22 -1
- package/skills/hydrogen-smoke-test/SKILL.md +2 -2
- package/skills/hydrogen-storefront-client/SKILL.md +4 -2
- package/skills/hydrogen-variant-form/SKILL.md +10 -4
- package/ts-plugin/package.json +5 -0
- package/dist/core/shopify-scripts/consent.mjs +0 -17
- package/dist/core/shopify-scripts/consent.mjs.map +0 -1
- package/dist/core/shopify-scripts/deprecated-cookies.mjs +0 -97
- package/dist/core/shopify-scripts/deprecated-cookies.mjs.map +0 -1
- package/dist/core/shopify-scripts/utils/cookie-domain.mjs +0 -43
- package/dist/core/shopify-scripts/utils/cookie-domain.mjs.map +0 -1
- package/dist/core/shopify-scripts/utils/tracking-values.mjs +0 -65
- package/dist/core/shopify-scripts/utils/tracking-values.mjs.map +0 -1
- package/dist/core/shopify-scripts/utils/uuid.mjs +0 -17
- package/dist/core/shopify-scripts/utils/uuid.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/consent.mjs +0 -17
- package/dist/development/core/shopify-scripts/consent.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/deprecated-cookies.mjs +0 -97
- package/dist/development/core/shopify-scripts/deprecated-cookies.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/utils/cookie-domain.mjs +0 -43
- package/dist/development/core/shopify-scripts/utils/cookie-domain.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/utils/tracking-values.mjs +0 -65
- package/dist/development/core/shopify-scripts/utils/tracking-values.mjs.map +0 -1
- package/dist/development/core/shopify-scripts/utils/uuid.mjs +0 -17
- package/dist/development/core/shopify-scripts/utils/uuid.mjs.map +0 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { getLogger } from "../logging/logging.mjs";
|
|
1
2
|
import { configureShopifyRouting } from "./global.mjs";
|
|
2
|
-
import {
|
|
3
|
+
import { initializeCustomConsent } from "../analytics/custom-consent.mjs";
|
|
3
4
|
import { initializeShopifyPageViewEvents } from "./page-view.mjs";
|
|
4
5
|
import { loadShopifyWebMcpTools } from "./webmcp.mjs";
|
|
5
6
|
|
|
6
7
|
//#region src/core/shopify-scripts/initialize.ts
|
|
8
|
+
const log = getLogger("consent");
|
|
7
9
|
/**
|
|
8
10
|
* Initializes Shopify browser script behavior for frameworks without a Hydrogen binding.
|
|
9
11
|
*
|
|
@@ -11,13 +13,18 @@ import { loadShopifyWebMcpTools } from "./webmcp.mjs";
|
|
|
11
13
|
* into a `ShopifyScripts` component, while custom integrations can render script tags during SSR
|
|
12
14
|
* and call this helper from their browser lifecycle.
|
|
13
15
|
*/
|
|
14
|
-
function initializeShopifyScripts({ navigate, routes, webMcp = true }) {
|
|
16
|
+
function initializeShopifyScripts({ consent, navigate, routes, webMcp = true }) {
|
|
15
17
|
configureShopifyRouting({
|
|
16
18
|
navigate,
|
|
17
19
|
routes
|
|
18
20
|
});
|
|
19
|
-
initializeDeprecatedCookies();
|
|
20
21
|
initializeShopifyPageViewEvents();
|
|
22
|
+
if (consent?.mode === "custom-banner") if (typeof consent.setup !== "function") log.warn("custom-banner requires a consent.setup callback; analytics delivery remains blocked until setup completes");
|
|
23
|
+
else try {
|
|
24
|
+
initializeCustomConsent(consent.setup);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
log.error("custom consent initialization failed", { error });
|
|
27
|
+
}
|
|
21
28
|
if (!webMcp) return Promise.resolve();
|
|
22
29
|
return loadShopifyWebMcpTools();
|
|
23
30
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"initialize.mjs","names":[],"sources":["../../../../src/core/shopify-scripts/initialize.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"initialize.mjs","names":[],"sources":["../../../../src/core/shopify-scripts/initialize.ts"],"sourcesContent":["import { initializeCustomConsent } from \"../analytics/custom-consent\";\nimport { getLogger } from \"../logging\";\nimport { configureShopifyRouting } from \"./global\";\nimport { initializeShopifyPageViewEvents } from \"./page-view\";\nimport type { InitializeShopifyScriptsOptions } from \"./types\";\nimport { loadShopifyWebMcpTools } from \"./webmcp\";\n\nconst log = getLogger(\"consent\");\n\n/**\n * Initializes Shopify browser script behavior for frameworks without a Hydrogen binding.\n *\n * This is the browser hydration half of `getShopifyScriptTags()`: framework bindings combine both\n * into a `ShopifyScripts` component, while custom integrations can render script tags during SSR\n * and call this helper from their browser lifecycle.\n */\nexport function initializeShopifyScripts({\n consent,\n navigate,\n routes,\n webMcp = true,\n}: InitializeShopifyScriptsOptions): Promise<boolean | void> {\n configureShopifyRouting({ navigate, routes });\n initializeShopifyPageViewEvents();\n\n if (consent?.mode === \"custom-banner\") {\n if (typeof consent.setup !== \"function\") {\n log.warn(\n \"custom-banner requires a consent.setup callback; analytics delivery remains blocked until setup completes\",\n );\n } else {\n try {\n initializeCustomConsent(consent.setup);\n } catch (error) {\n log.error(\"custom consent initialization failed\", { error });\n }\n }\n }\n\n if (!webMcp) return Promise.resolve();\n\n return loadShopifyWebMcpTools();\n}\n"],"mappings":";;;;;;;AAOA,MAAM,MAAM,UAAU,UAAU;;;;;;;;AAShC,SAAgB,yBAAyB,EACvC,SACA,UACA,QACA,SAAS,QACkD;AAC3D,yBAAwB;EAAE;EAAU;EAAQ,CAAC;AAC7C,kCAAiC;AAEjC,KAAI,SAAS,SAAS,gBACpB,KAAI,OAAO,QAAQ,UAAU,WAC3B,KAAI,KACF,4GACD;KAED,KAAI;AACF,0BAAwB,QAAQ,MAAM;UAC/B,OAAO;AACd,MAAI,MAAM,wCAAwC,EAAE,OAAO,CAAC;;AAKlE,KAAI,CAAC,OAAQ,QAAO,QAAQ,SAAS;AAErC,QAAO,wBAAwB"}
|
|
@@ -52,6 +52,11 @@ type ShopifyScriptsShop = {
|
|
|
52
52
|
myshopifyDomain: string;
|
|
53
53
|
};
|
|
54
54
|
type ShopifyScriptTagsOptions = {
|
|
55
|
+
/**
|
|
56
|
+
* Loads the customer account component. Render `<shopify-account>` where you want the account UI to appear.
|
|
57
|
+
* @see {@link https://shopify.dev/docs/api/storefront-web-components/components/shopify-account | shopify-account}
|
|
58
|
+
*/
|
|
59
|
+
account?: boolean;
|
|
55
60
|
analytics?: ShopifyScriptsAnalyticsConfig;
|
|
56
61
|
consent?: ConsentConfig;
|
|
57
62
|
debug?: {
|
|
@@ -68,6 +73,7 @@ type ShopifyRoutesOptions = {
|
|
|
68
73
|
routes?: ShopifyRouteTemplates;
|
|
69
74
|
};
|
|
70
75
|
type InitializeShopifyScriptsOptions = ShopifyRoutesOptions & {
|
|
76
|
+
/** Pass the same consent configuration used to render the script tags. Setup runs once per bus. */consent?: ConsentConfig;
|
|
71
77
|
webMcp?: boolean;
|
|
72
78
|
};
|
|
73
79
|
type ShopifyScriptsOptions = ShopifyScriptTagsOptions & InitializeShopifyScriptsOptions;
|
|
@@ -3,8 +3,11 @@ const SFAPI_RE = /^\/api\/(unstable|2\d{3}-\d{2})\/graphql\.json$/;
|
|
|
3
3
|
const SHOPIFY_API_PROXY_PREFIX = "/__shopify";
|
|
4
4
|
const SHOPIFY_API_PROXY_RE = /^\/__shopify(?:\/|$)/;
|
|
5
5
|
const MCP_RE = /^\/api\/mcp$/;
|
|
6
|
+
const UCP_MCP_RE = /^\/api\/ucp\/mcp$/;
|
|
6
7
|
const CHECKOUT_RE = /^\/checkout$/;
|
|
7
8
|
const CART_PERMALINK_RE = /^\/cart\/\d+:\d+(?:,\d+:\d+)*$/;
|
|
9
|
+
const BUY_ITEM_PAIR = String.raw`(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9]\d*`;
|
|
10
|
+
const BUY_PERMALINK_RE = new RegExp(`^/buy/${BUY_ITEM_PAIR}(?:,${BUY_ITEM_PAIR})*$`);
|
|
8
11
|
const CUSTOMER_ACCOUNT_PATHS = {
|
|
9
12
|
authorize: "/account/authorize",
|
|
10
13
|
login: "/account/login",
|
|
@@ -19,7 +22,7 @@ const CUSTOMER_ACCOUNT_HANDOFF_PATHS = new Set(Object.values(CUSTOMER_ACCOUNT_PA
|
|
|
19
22
|
* interceptors are intentionally excluded because they are not browser navigation destinations.
|
|
20
23
|
*/
|
|
21
24
|
function isHydrogenServerHandoffPath(pathname) {
|
|
22
|
-
return CHECKOUT_RE.test(pathname) || CART_PERMALINK_RE.test(pathname) || CUSTOMER_ACCOUNT_HANDOFF_PATHS.has(pathname);
|
|
25
|
+
return CHECKOUT_RE.test(pathname) || CART_PERMALINK_RE.test(pathname) || BUY_PERMALINK_RE.test(pathname) || CUSTOMER_ACCOUNT_HANDOFF_PATHS.has(pathname);
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* Allowlisted `.well-known` resources proxied to the Online Store origin:
|
|
@@ -31,6 +34,22 @@ function isHydrogenServerHandoffPath(pathname) {
|
|
|
31
34
|
*/
|
|
32
35
|
const WELL_KNOWN_RE = /^\/\.well-known\/(?:apple-developer-merchantid-domain-association|shopify\/fec\/produce)$/;
|
|
33
36
|
const AJAX_CART_RE = /^(?:\/[a-z]{2}(?:-[a-z]{2})?)?\/cart(?:\.(?:js|json)|\/(?:add|update|change|clear)(?:\.(?:js|json))?)$/i;
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes `target` to a path on `origin` that is safe to use as a redirect location.
|
|
39
|
+
* Returns `undefined` unless `target` is a path starting with `/` or an absolute URL,
|
|
40
|
+
* stays on `origin`, and its normalized pathname does not start with `//`.
|
|
41
|
+
*/
|
|
42
|
+
function getSameOriginPath(target, origin) {
|
|
43
|
+
if (!target) return void 0;
|
|
44
|
+
try {
|
|
45
|
+
const url = target.startsWith("/") ? new URL(target, origin) : new URL(target);
|
|
46
|
+
if (url.origin !== origin) return void 0;
|
|
47
|
+
const path = `${url.pathname}${url.search}${url.hash}`;
|
|
48
|
+
return path.startsWith("//") ? void 0 : path;
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
34
53
|
function normalizeStoreDomain(domain) {
|
|
35
54
|
if (!domain) throw new Error("Storefront `storeDomain` is required.");
|
|
36
55
|
if (domain.startsWith("http://") || domain.startsWith("https://")) return domain.replace(/\/+$/, "");
|
|
@@ -38,5 +57,5 @@ function normalizeStoreDomain(domain) {
|
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
//#endregion
|
|
41
|
-
export { AJAX_CART_RE, CHECKOUT_RE, CUSTOMER_ACCOUNT_PATHS, MCP_RE, SFAPI_RE, SHOPIFY_API_PROXY_PREFIX, SHOPIFY_API_PROXY_RE, WELL_KNOWN_RE, isHydrogenServerHandoffPath, normalizeStoreDomain };
|
|
60
|
+
export { AJAX_CART_RE, BUY_PERMALINK_RE, CHECKOUT_RE, CUSTOMER_ACCOUNT_PATHS, MCP_RE, SFAPI_RE, SHOPIFY_API_PROXY_PREFIX, SHOPIFY_API_PROXY_RE, UCP_MCP_RE, WELL_KNOWN_RE, getSameOriginPath, isHydrogenServerHandoffPath, normalizeStoreDomain };
|
|
42
61
|
//# sourceMappingURL=url.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"url.mjs","names":[],"sources":["../../../src/core/url.ts"],"sourcesContent":["export const SFAPI_RE = /^\\/api\\/(unstable|2\\d{3}-\\d{2})\\/graphql\\.json$/;\nexport const SHOPIFY_API_PROXY_PREFIX = \"/__shopify\";\nexport const SHOPIFY_API_PROXY_RE = /^\\/__shopify(?:\\/|$)/;\nexport const MCP_RE = /^\\/api\\/mcp$/;\nexport const CHECKOUT_RE = /^\\/checkout$/;\nexport const CART_PERMALINK_RE = /^\\/cart\\/\\d+:\\d+(?:,\\d+:\\d+)*$/;\nexport const CUSTOMER_ACCOUNT_PATHS = {\n authorize: \"/account/authorize\",\n login: \"/account/login\",\n logout: \"/account/logout\",\n refresh: \"/account/refresh\",\n} as const;\n\nconst CUSTOMER_ACCOUNT_HANDOFF_PATHS: ReadonlySet<string> = new Set(\n Object.values(CUSTOMER_ACCOUNT_PATHS),\n);\n\n/**\n * Returns whether the pathname conventionally represents a Hydrogen document-level server handoff.\n *\n * Keep this aligned with the routes intercepted before framework routing. API and protocol\n * interceptors are intentionally excluded because they are not browser navigation destinations.\n */\nexport function isHydrogenServerHandoffPath(pathname: string): boolean {\n return (\n CHECKOUT_RE.test(pathname) ||\n CART_PERMALINK_RE.test(pathname) ||\n CUSTOMER_ACCOUNT_HANDOFF_PATHS.has(pathname)\n );\n}\n\n/**\n * Allowlisted `.well-known` resources proxied to the Online Store origin:\n * - `apple-developer-merchantid-domain-association` — Apple Pay domain verification.\n * - `shopify/fec/produce` — Frontend Event Collector ingress. On the Online\n * Store the myshopify.com edge forwards this first-party path to the\n * collector; headless storefronts have no such edge, so Hydrogen proxies it\n * to the same origin (used by WebMCP's Event Refinery client).\n */\nexport const WELL_KNOWN_RE =\n /^\\/\\.well-known\\/(?:apple-developer-merchantid-domain-association|shopify\\/fec\\/produce)$/;\nexport const AJAX_CART_RE =\n /^(?:\\/[a-z]{2}(?:-[a-z]{2})?)?\\/cart(?:\\.(?:js|json)|\\/(?:add|update|change|clear)(?:\\.(?:js|json))?)$/i;\n\nexport function normalizeStoreDomain(domain: string): string {\n if (!domain) {\n throw new Error(\"Storefront `storeDomain` is required.\");\n }\n\n if (domain.startsWith(\"http://\") || domain.startsWith(\"https://\")) {\n return domain.replace(/\\/+$/, \"\");\n }\n return `https://${domain}`.replace(/\\/+$/, \"\");\n}\n"],"mappings":";AAAA,MAAa,WAAW;AACxB,MAAa,2BAA2B;AACxC,MAAa,uBAAuB;AACpC,MAAa,SAAS;AACtB,MAAa,cAAc;AAC3B,MAAa,oBAAoB;AACjC,MAAa,yBAAyB;CACpC,WAAW;CACX,OAAO;CACP,QAAQ;CACR,SAAS;CACV;AAED,MAAM,iCAAsD,IAAI,IAC9D,OAAO,OAAO,uBAAuB,CACtC;;;;;;;AAQD,SAAgB,4BAA4B,UAA2B;AACrE,QACE,YAAY,KAAK,SAAS,IAC1B,kBAAkB,KAAK,SAAS,IAChC,+BAA+B,IAAI,SAAS;;;;;;;;;;AAYhD,MAAa,gBACX;AACF,MAAa,eACX;
|
|
1
|
+
{"version":3,"file":"url.mjs","names":[],"sources":["../../../src/core/url.ts"],"sourcesContent":["export const SFAPI_RE = /^\\/api\\/(unstable|2\\d{3}-\\d{2})\\/graphql\\.json$/;\nexport const SHOPIFY_API_PROXY_PREFIX = \"/__shopify\";\nexport const SHOPIFY_API_PROXY_RE = /^\\/__shopify(?:\\/|$)/;\nexport const MCP_RE = /^\\/api\\/mcp$/;\nexport const UCP_MCP_RE = /^\\/api\\/ucp\\/mcp$/;\nexport const CHECKOUT_RE = /^\\/checkout$/;\nexport const CART_PERMALINK_RE = /^\\/cart\\/\\d+:\\d+(?:,\\d+:\\d+)*$/;\nconst BUY_ITEM_PAIR = String.raw`(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9]\\d*`;\n// Items are required so a storefront's own /buy pages keep routing to the app.\nexport const BUY_PERMALINK_RE = new RegExp(`^/buy/${BUY_ITEM_PAIR}(?:,${BUY_ITEM_PAIR})*$`);\nexport const CUSTOMER_ACCOUNT_PATHS = {\n authorize: \"/account/authorize\",\n login: \"/account/login\",\n logout: \"/account/logout\",\n refresh: \"/account/refresh\",\n} as const;\n\nconst CUSTOMER_ACCOUNT_HANDOFF_PATHS: ReadonlySet<string> = new Set(\n Object.values(CUSTOMER_ACCOUNT_PATHS),\n);\n\n/**\n * Returns whether the pathname conventionally represents a Hydrogen document-level server handoff.\n *\n * Keep this aligned with the routes intercepted before framework routing. API and protocol\n * interceptors are intentionally excluded because they are not browser navigation destinations.\n */\nexport function isHydrogenServerHandoffPath(pathname: string): boolean {\n return (\n CHECKOUT_RE.test(pathname) ||\n CART_PERMALINK_RE.test(pathname) ||\n BUY_PERMALINK_RE.test(pathname) ||\n CUSTOMER_ACCOUNT_HANDOFF_PATHS.has(pathname)\n );\n}\n\n/**\n * Allowlisted `.well-known` resources proxied to the Online Store origin:\n * - `apple-developer-merchantid-domain-association` — Apple Pay domain verification.\n * - `shopify/fec/produce` — Frontend Event Collector ingress. On the Online\n * Store the myshopify.com edge forwards this first-party path to the\n * collector; headless storefronts have no such edge, so Hydrogen proxies it\n * to the same origin (used by WebMCP's Event Refinery client).\n */\nexport const WELL_KNOWN_RE =\n /^\\/\\.well-known\\/(?:apple-developer-merchantid-domain-association|shopify\\/fec\\/produce)$/;\nexport const AJAX_CART_RE =\n /^(?:\\/[a-z]{2}(?:-[a-z]{2})?)?\\/cart(?:\\.(?:js|json)|\\/(?:add|update|change|clear)(?:\\.(?:js|json))?)$/i;\n\n/**\n * Normalizes `target` to a path on `origin` that is safe to use as a redirect location.\n * Returns `undefined` unless `target` is a path starting with `/` or an absolute URL,\n * stays on `origin`, and its normalized pathname does not start with `//`.\n */\nexport function getSameOriginPath(\n target: string | null | undefined,\n origin: string,\n): string | undefined {\n if (!target) return undefined;\n\n try {\n // Parse absolute URLs without a base so scheme-relative forms like `https:evil.example`\n // resolve to their own host, as a browser would, instead of onto `origin`.\n const url = target.startsWith(\"/\") ? new URL(target, origin) : new URL(target);\n if (url.origin !== origin) return undefined;\n const path = `${url.pathname}${url.search}${url.hash}`;\n // The parsed pathname can itself start with `//` (e.g. from `/x/..//evil`),\n // which a redirect would resolve as a protocol-relative URL to another host.\n return path.startsWith(\"//\") ? undefined : path;\n } catch {\n return undefined;\n }\n}\n\nexport function normalizeStoreDomain(domain: string): string {\n if (!domain) {\n throw new Error(\"Storefront `storeDomain` is required.\");\n }\n\n if (domain.startsWith(\"http://\") || domain.startsWith(\"https://\")) {\n return domain.replace(/\\/+$/, \"\");\n }\n return `https://${domain}`.replace(/\\/+$/, \"\");\n}\n"],"mappings":";AAAA,MAAa,WAAW;AACxB,MAAa,2BAA2B;AACxC,MAAa,uBAAuB;AACpC,MAAa,SAAS;AACtB,MAAa,aAAa;AAC1B,MAAa,cAAc;AAC3B,MAAa,oBAAoB;AACjC,MAAM,gBAAgB,OAAO,GAAG;AAEhC,MAAa,mBAAmB,IAAI,OAAO,SAAS,cAAc,MAAM,cAAc,KAAK;AAC3F,MAAa,yBAAyB;CACpC,WAAW;CACX,OAAO;CACP,QAAQ;CACR,SAAS;CACV;AAED,MAAM,iCAAsD,IAAI,IAC9D,OAAO,OAAO,uBAAuB,CACtC;;;;;;;AAQD,SAAgB,4BAA4B,UAA2B;AACrE,QACE,YAAY,KAAK,SAAS,IAC1B,kBAAkB,KAAK,SAAS,IAChC,iBAAiB,KAAK,SAAS,IAC/B,+BAA+B,IAAI,SAAS;;;;;;;;;;AAYhD,MAAa,gBACX;AACF,MAAa,eACX;;;;;;AAOF,SAAgB,kBACd,QACA,QACoB;AACpB,KAAI,CAAC,OAAQ,QAAO;AAEpB,KAAI;EAGF,MAAM,MAAM,OAAO,WAAW,IAAI,GAAG,IAAI,IAAI,QAAQ,OAAO,GAAG,IAAI,IAAI,OAAO;AAC9E,MAAI,IAAI,WAAW,OAAQ,QAAO;EAClC,MAAM,OAAO,GAAG,IAAI,WAAW,IAAI,SAAS,IAAI;AAGhD,SAAO,KAAK,WAAW,KAAK,GAAG,SAAY;SACrC;AACN;;;AAIJ,SAAgB,qBAAqB,QAAwB;AAC3D,KAAI,CAAC,OACH,OAAM,IAAI,MAAM,wCAAwC;AAG1D,KAAI,OAAO,WAAW,UAAU,IAAI,OAAO,WAAW,WAAW,CAC/D,QAAO,OAAO,QAAQ,QAAQ,GAAG;AAEnC,QAAO,WAAW,SAAS,QAAQ,QAAQ,GAAG"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ShopifyRequestContext } from "../core/request-context.mjs";
|
|
2
2
|
import { ShopifyRouteSessionManager } from "../core/request-routing/route-types.mjs";
|
|
3
3
|
//#region src/customer-account/session.d.ts
|
|
4
|
+
/** A value that is either `T` or a `Promise<T>`, used as the return type of session manager methods. */
|
|
4
5
|
type Awaitable<T> = T | Promise<T>;
|
|
5
6
|
/** Read-only Customer Account session storage for UI state and strict access-token reads. */
|
|
6
7
|
type ReadonlyCustomerSessionManager = {
|
|
@@ -8,37 +9,92 @@ type ReadonlyCustomerSessionManager = {
|
|
|
8
9
|
};
|
|
9
10
|
/** Writable Customer Account session storage for OAuth, refresh, and logout response boundaries. */
|
|
10
11
|
type WritableCustomerSessionManager = ShopifyRouteSessionManager;
|
|
12
|
+
/** Options for {@link CustomerSession.prepareLoginUrl}. */
|
|
11
13
|
type PrepareLoginUrlOptions = {
|
|
14
|
+
/**
|
|
15
|
+
* Overrides the origin used for the OAuth redirect URI. Must be HTTPS —
|
|
16
|
+
* unlike `createCustomerAccountClient`, `http://localhost` is not accepted.
|
|
17
|
+
* Falls back to the session manager's `getSessionOrigin()`.
|
|
18
|
+
*/
|
|
12
19
|
origin?: string;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Same-origin path or URL to redirect back to after login, such as `"/account/orders"`.
|
|
22
|
+
* Cross-origin values and values over 2 048 bytes fall back to the default, `"/account"`.
|
|
23
|
+
*/
|
|
24
|
+
returnTo?: string; /** Passed as the `locale` search param on the Shopify OAuth authorize URL. */
|
|
25
|
+
locale?: string; /** Passed as the `region_country` search param on the Shopify OAuth authorize URL. */
|
|
26
|
+
countryCode?: string; /** Email address to pre-fill on the login screen. Passed as `login_hint`. */
|
|
27
|
+
loginHint?: string; /** When set to `"submit"` (and `loginHint` is provided), skips the login form and sends the one-time code immediately. Passed as `login_hint_mode`. */
|
|
28
|
+
loginHintMode?: string; /** Passed as the `acr_values` search param for authentication context class. */
|
|
18
29
|
acrValues?: string;
|
|
19
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Origin override accepted by {@link CustomerSession.getOrRefreshAccessToken}
|
|
33
|
+
* and {@link CustomerSession.logout}. Falls back to the session manager's
|
|
34
|
+
* `getSessionOrigin()`.
|
|
35
|
+
*/
|
|
20
36
|
type RequestOriginOptions = {
|
|
21
|
-
origin?: string;
|
|
37
|
+
/** HTTPS origin string. */origin?: string;
|
|
22
38
|
};
|
|
39
|
+
/** Options for {@link CustomerSession.logout}. */
|
|
23
40
|
type LogoutOptions = RequestOriginOptions & {
|
|
41
|
+
/**
|
|
42
|
+
* URL to redirect to after Shopify's IdP logout completes.
|
|
43
|
+
* Resolved as an absolute same-origin URL. Defaults to `"/"`.
|
|
44
|
+
*/
|
|
24
45
|
postLogoutRedirectUri?: string;
|
|
25
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Core session interface returned by {@link createCustomerSession}. Server-only.
|
|
49
|
+
* All methods require a session manager and request context.
|
|
50
|
+
*/
|
|
26
51
|
type CustomerSession = {
|
|
27
52
|
/**
|
|
28
|
-
* Read-only signed-in check for UI state. Returns true
|
|
29
|
-
*
|
|
30
|
-
*
|
|
53
|
+
* Read-only signed-in check for UI state. Returns `true` when a usable access
|
|
54
|
+
* token OR a refresh token exists. Does not attempt a refresh. Use for
|
|
55
|
+
* conditional UI rendering, not for authorizing access to Customer Account data.
|
|
56
|
+
*/
|
|
57
|
+
isLoggedIn(sessionManager: ReadonlyCustomerSessionManager, requestContext: ShopifyRequestContext): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Returns only a currently usable access token, or `undefined` when the token
|
|
60
|
+
* is missing or expired. Never refreshes and never writes session state.
|
|
31
61
|
*/
|
|
32
|
-
isLoggedIn(sessionManager: ReadonlyCustomerSessionManager, requestContext: ShopifyRequestContext): Promise<boolean>; /** Returns only a currently usable access token. Does not refresh or mutate session state. */
|
|
33
62
|
getAccessToken(sessionManager: ReadonlyCustomerSessionManager, requestContext: ShopifyRequestContext): Promise<string | undefined>;
|
|
34
63
|
/**
|
|
35
|
-
* Returns a usable access token,
|
|
36
|
-
*
|
|
37
|
-
*
|
|
64
|
+
* Returns a usable access token, refreshing from Shopify's token endpoint and
|
|
65
|
+
* writing updated tokens through the writable session manager when needed.
|
|
66
|
+
* Returns `undefined` when no refresh token exists (unauthenticated) or when
|
|
67
|
+
* the refresh fails transiently (the session is preserved for retry). A
|
|
68
|
+
* 400/401 from the token endpoint clears stored tokens. Never throws for
|
|
69
|
+
* refresh failures. Concurrent refresh requests for the same origin+token
|
|
70
|
+
* combination are deduplicated per `createCustomerSession` instance. Use only
|
|
71
|
+
* where the eventual response commits the session manager.
|
|
38
72
|
*/
|
|
39
73
|
getOrRefreshAccessToken(sessionManager: WritableCustomerSessionManager, requestContext: ShopifyRequestContext, options?: RequestOriginOptions): Promise<string | undefined>;
|
|
74
|
+
/**
|
|
75
|
+
* Builds a Shopify OAuth authorize URL with PKCE (S256). Stores pending login
|
|
76
|
+
* state (state, nonce, code verifier, returnTo, origin) in the session. Pending
|
|
77
|
+
* login state expires after 10 minutes. Returns the full authorize URL string.
|
|
78
|
+
*/
|
|
40
79
|
prepareLoginUrl(sessionManager: WritableCustomerSessionManager, requestContext: ShopifyRequestContext, options: PrepareLoginUrlOptions): Promise<string>;
|
|
80
|
+
/**
|
|
81
|
+
* Completes the OAuth authorization code exchange. Validates `state`, exchanges
|
|
82
|
+
* the code for tokens, validates the `id_token` claims (nonce, issuer, audience,
|
|
83
|
+
* expiry), and stores tokens in the session. Returns the `returnTo` path. Clears
|
|
84
|
+
* pending login state on failure.
|
|
85
|
+
*
|
|
86
|
+
* @throws {CustomerAccountOAuthError} On validation failures (`missing_callback_params`,
|
|
87
|
+
* `state_mismatch`, `missing_pending_login`, `token_exchange_rejected`,
|
|
88
|
+
* `token_exchange_failed`, `invalid_token_response`, or id_token claim mismatches).
|
|
89
|
+
* @throws {CustomerAccountApiError} On token-request timeout.
|
|
90
|
+
*/
|
|
41
91
|
handleOAuthCallback(sessionManager: WritableCustomerSessionManager, requestContext: ShopifyRequestContext, request: Request): Promise<string>;
|
|
92
|
+
/**
|
|
93
|
+
* Clears session tokens. When an `id_token` exists, returns the Shopify IdP
|
|
94
|
+
* logout URL (with `id_token_hint` and `post_logout_redirect_uri`). When no
|
|
95
|
+
* `id_token` is stored, returns the `postLogoutRedirectUri` directly (local-only
|
|
96
|
+
* logout, no IdP round-trip).
|
|
97
|
+
*/
|
|
42
98
|
logout(sessionManager: WritableCustomerSessionManager, requestContext: ShopifyRequestContext, options?: LogoutOptions): Promise<string>;
|
|
43
99
|
};
|
|
44
100
|
//#endregion
|
|
@@ -3,9 +3,13 @@ import { CUSTOMER_ACCOUNT_PATHS } from "../core/url.mjs";
|
|
|
3
3
|
|
|
4
4
|
//#region src/customer-account/session.ts
|
|
5
5
|
const log = getLogger("customer-account");
|
|
6
|
+
/** OAuth callback route path: `"/account/authorize"`. */
|
|
6
7
|
const CUSTOMER_ACCOUNT_AUTHORIZE_PATH = CUSTOMER_ACCOUNT_PATHS.authorize;
|
|
8
|
+
/** Login route path: `"/account/login"`. */
|
|
7
9
|
const CUSTOMER_ACCOUNT_LOGIN_PATH = CUSTOMER_ACCOUNT_PATHS.login;
|
|
10
|
+
/** Logout route path: `"/account/logout"`. */
|
|
8
11
|
const CUSTOMER_ACCOUNT_LOGOUT_PATH = CUSTOMER_ACCOUNT_PATHS.logout;
|
|
12
|
+
/** Token refresh route path: `"/account/refresh"`. */
|
|
9
13
|
const CUSTOMER_ACCOUNT_REFRESH_PATH = CUSTOMER_ACCOUNT_PATHS.refresh;
|
|
10
14
|
const PENDING_LOGIN_TTL_IN_MINUTES = 10;
|
|
11
15
|
const SECONDS_PER_MINUTE = 60;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.mjs","names":[],"sources":["../../../src/customer-account/session.ts"],"sourcesContent":["import type { StorefrontClient } from \"../client\";\nimport {\n getCartBuyerIdentitySync,\n type CartBuyerIdentitySync,\n type CartBuyerIdentitySyncSource,\n} from \"../core/cart/buyer-identity-sync\";\nimport { DEFAULT_TIMEOUT_IN_MS } from \"../core/constants\";\nimport { getLogger } from \"../core/logging\";\nimport type { ShopifyRequestContext } from \"../core/request-context\";\nimport {\n createCallableRouteHandler,\n type CallableRouteHandler,\n type ShopifyRouteErrorResult,\n type ShopifyRouteHandlerContext,\n type ShopifyRouteRedirectResult,\n type ShopifyRouteSessionManager,\n} from \"../core/request-routing/registered-routes\";\nimport { CUSTOMER_ACCOUNT_PATHS } from \"../core/url\";\nimport { CustomerAccountApiError, CustomerAccountOAuthError } from \"./errors\";\n\nconst log = getLogger(\"customer-account\");\n\nexport const CUSTOMER_ACCOUNT_AUTHORIZE_PATH = CUSTOMER_ACCOUNT_PATHS.authorize;\nexport const CUSTOMER_ACCOUNT_LOGIN_PATH = CUSTOMER_ACCOUNT_PATHS.login;\nexport const CUSTOMER_ACCOUNT_LOGOUT_PATH = CUSTOMER_ACCOUNT_PATHS.logout;\nexport const CUSTOMER_ACCOUNT_REFRESH_PATH = CUSTOMER_ACCOUNT_PATHS.refresh;\n\nconst CUSTOMER_ACCOUNT_SESSION_KEY = \"customerAccount\";\nconst DEFAULT_LOGIN_RETURN_TO_PATH = \"/account\";\nconst DEFAULT_POST_LOGIN_REDIRECT_PATHNAME = \"/\";\nconst DEFAULT_POST_LOGOUT_REDIRECT_URI = \"/\";\nconst FAILED_LOGIN_PATH = \"/account?login=failed\";\nconst FORBIDDEN_STATUS = 403;\nconst FORBIDDEN_ERROR_CODE = \"forbidden\";\nconst FORBIDDEN_ERROR_MESSAGE = \"Forbidden\";\nconst NO_STORE_CACHE_CONTROL = \"no-store\";\nconst AUTHORIZATION_CODE_GRANT_TYPE = \"authorization_code\";\nconst REFRESH_TOKEN_GRANT_TYPE = \"refresh_token\";\nconst CUSTOMER_ACCOUNT_SCOPE = \"openid email customer-account-api:full\";\nconst CODE_CHALLENGE_METHOD = \"S256\";\nconst USER_AGENT = `Hydrogen ${__HYDROGEN_VERSION__}`;\nconst EXPIRY_BUFFER_IN_SECONDS = 120;\nconst PENDING_LOGIN_TTL_IN_MINUTES = 10;\nconst SECONDS_PER_MINUTE = 60;\nconst MILLISECONDS_PER_SECOND = 1_000;\nconst PENDING_LOGIN_TTL_IN_MS =\n PENDING_LOGIN_TTL_IN_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;\nconst RANDOM_BYTES_LENGTH = 32;\nconst MAX_RETURN_TO_LENGTH_IN_BYTES = 2_048;\nconst MAX_SET_TIMEOUT_IN_MS = 2_147_483_647;\nconst SHOP_ID_RE = /^\\d+$/;\nconst CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON = \"customer-session-access-token\";\nconst CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON = \"customer-session-mutation\";\nconst CUSTOMER_SESSION_INTERNAL_BRAND: unique symbol = Symbol(\"hydrogen.customerSessionInternal\");\n\nexport type Awaitable<T> = T | Promise<T>;\n\n/** Read-only Customer Account session storage for UI state and strict access-token reads. */\nexport type ReadonlyCustomerSessionManager = {\n getSessionItem(key: string): Awaitable<unknown>;\n};\n\n/** Writable Customer Account session storage for OAuth, refresh, and logout response boundaries. */\nexport type WritableCustomerSessionManager = ShopifyRouteSessionManager;\n\ntype CustomerAccountSessionData = {\n tokens?: {\n accessToken?: string;\n refreshToken?: string;\n idToken?: string;\n expiresAt?: number;\n };\n pendingLogin?: {\n state?: string;\n nonce?: string;\n codeVerifier?: string;\n returnTo?: string;\n origin?: string;\n createdAt?: number;\n };\n};\n\nexport type CreateCustomerSessionOptions = {\n shopId: string;\n customerAccountApiClientId: string;\n customerAccountApiUrl?: string;\n fetch?: typeof globalThis.fetch;\n defaultTimeoutInMs?: number;\n};\n\nexport type PrepareLoginUrlOptions = {\n origin?: string;\n returnTo?: string;\n locale?: string;\n countryCode?: string;\n loginHint?: string;\n loginHintMode?: string;\n acrValues?: string;\n};\n\nexport type RequestOriginOptions = {\n origin?: string;\n};\n\nexport type LogoutOptions = RequestOriginOptions & {\n postLogoutRedirectUri?: string;\n};\n\nexport type CustomerSession = {\n /**\n * Read-only signed-in check for UI state. Returns true for a usable access token\n * or a refresh token that can attempt to restore one later. Not an authorization\n * check for private Customer Account data.\n */\n isLoggedIn(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ): Promise<boolean>;\n /** Returns only a currently usable access token. Does not refresh or mutate session state. */\n getAccessToken(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ): Promise<string | undefined>;\n /**\n * Returns a usable access token, writing updated session state through the\n * supplied writable session manager when no usable access token is stored. Use\n * only where the eventual response commits the session manager.\n */\n getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: RequestOriginOptions,\n ): Promise<string | undefined>;\n prepareLoginUrl(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: PrepareLoginUrlOptions,\n ): Promise<string>;\n handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<string>;\n logout(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: LogoutOptions,\n ): Promise<string>;\n};\n\nexport type CustomerAccountServerHandlers<\n TContext extends CustomerAccountRouteHandlerContext = CustomerAccountRouteHandlerContext,\n> = {\n authorize: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_AUTHORIZE_PATH,\n \"GET\"\n >;\n login: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_LOGIN_PATH,\n \"GET\"\n >;\n logout: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_LOGOUT_PATH,\n \"POST\"\n >;\n refresh: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_REFRESH_PATH,\n \"GET\"\n >;\n};\n\n/** Customer Account handlers that also synchronize cart buyer identity, so their context requires `storefrontClient`. */\nexport type CustomerAccountServerHandlersWithCartSync =\n CustomerAccountServerHandlers<ShopifyRouteHandlerContext>;\n\ntype CustomerSessionWithInternals = CustomerSession & {\n readonly [CUSTOMER_SESSION_INTERNAL_BRAND]: true;\n};\n\ntype CreateCustomerAccountServerHandlersBaseOptions<\n TCustomerSession extends CustomerSession = CustomerSession,\n> = {\n customerSession: TCustomerSession;\n defaultPostLoginRedirectPathname?: string;\n loginFailedRedirectPath?: string;\n origin?: string | ((request: Request) => string);\n postLogoutRedirectUri?: string;\n};\n\nexport type CreateCustomerAccountServerHandlersOptions =\n | (CreateCustomerAccountServerHandlersBaseOptions & { cartServerHandlers?: undefined })\n | (CreateCustomerAccountServerHandlersBaseOptions<CustomerSessionWithInternals> & {\n /**\n * Cart server handlers created with `createCartServerHandlers({customerSession})`.\n * When provided, the authorize, refresh, and logout routes keep the browser\n * cart's buyer identity in step with the customer session: attach on login\n * and refresh, detach on logout. Sync is best-effort — failures are logged\n * and never block the route's redirect; a failed detach during logout\n * expires the cart cookie instead.\n */\n cartServerHandlers: CartBuyerIdentitySyncSource;\n });\n\ntype CustomerAccountTokens = NonNullable<CustomerAccountSessionData[\"tokens\"]>;\ntype PendingLogin = NonNullable<CustomerAccountSessionData[\"pendingLogin\"]>;\ntype TokenEndpointResponse = {\n access_token?: unknown;\n refresh_token?: unknown;\n id_token?: unknown;\n expires_in?: unknown;\n};\ntype ParsedTokenEndpointResponse = {\n access_token: string;\n refresh_token?: string;\n id_token?: string;\n expires_in: number;\n};\ntype RefreshResult =\n | { type: \"success\"; tokens: CustomerAccountTokens }\n | { type: \"invalid\" }\n | { type: \"transient\" };\ntype TokenRefreshResult =\n | {\n /** A usable access token is available. */\n status: \"authenticated\";\n accessToken: string;\n }\n | {\n /** Refresh failed transiently, but the refreshable session remains. */\n status: \"transient\";\n accessToken: undefined;\n }\n | {\n /** No usable or refreshable customer session remains. */\n status: \"unauthenticated\";\n accessToken: undefined;\n };\ntype OAuthCallbackResult = {\n location: string;\n accessToken: string;\n};\ntype CustomerSessionInternals = {\n getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: RequestOriginOptions,\n ): Promise<TokenRefreshResult>;\n handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<OAuthCallbackResult>;\n};\nconst customerSessionInternals = new WeakMap<CustomerSession, CustomerSessionInternals>();\ntype CustomerAccountRouteResult = ShopifyRouteRedirectResult | ShopifyRouteErrorResult;\ntype CustomerAccountRouteHandlerContext = {\n request: Request;\n sessionManager: WritableCustomerSessionManager;\n requestContext: ShopifyRequestContext;\n};\ntype CustomerAccountRuntimeRouteHandlerContext = CustomerAccountRouteHandlerContext & {\n storefrontClient?: StorefrontClient;\n};\ntype CustomerAccountServerHandlersForOptions<TOptions> = TOptions extends {\n cartServerHandlers: CartBuyerIdentitySyncSource;\n}\n ? CustomerAccountServerHandlersWithCartSync\n : CustomerAccountServerHandlers;\ntype TokenRequestParams = {\n url: string;\n origin: string;\n body: URLSearchParams;\n fetch: typeof globalThis.fetch;\n signal: AbortSignal;\n};\n\nexport function createCustomerSession({\n shopId,\n customerAccountApiClientId,\n customerAccountApiUrl,\n fetch: customFetch,\n defaultTimeoutInMs = DEFAULT_TIMEOUT_IN_MS,\n}: CreateCustomerSessionOptions): CustomerSessionWithInternals {\n if (typeof document !== \"undefined\") {\n throw new Error(\n \"Customer Account OAuth sessions cannot be used in a browser context. Use this helper from server or edge routes only.\",\n );\n }\n\n validateShopId(shopId);\n validateCustomerAccountApiClientId(customerAccountApiClientId);\n validateTimeout(defaultTimeoutInMs);\n\n const fetch = customFetch ?? globalThis.fetch;\n if (typeof fetch !== \"function\") {\n throw new Error(\n \"No fetch function available. Pass a fetch option or ensure globalThis.fetch exists.\",\n );\n }\n\n const endpoints = createCustomerAccountEndpoints(shopId, customerAccountApiUrl);\n const refreshFlights = new Map<string, Promise<RefreshResult>>();\n\n async function getAccessToken(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON);\n const accessToken = getUsableAccessToken(await readSessionData(sessionManager));\n return accessToken;\n }\n\n async function getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n ) {\n const result = await getOrRefreshAccessTokenResult(sessionManager, requestContext, options);\n return result.accessToken;\n }\n\n async function getOrRefreshAccessTokenResult(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n ): Promise<TokenRefreshResult> {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON);\n const sessionData = await readSessionData(sessionManager);\n const accessToken = getUsableAccessToken(sessionData);\n if (accessToken) return { status: \"authenticated\", accessToken };\n\n const refreshToken = getRefreshToken(sessionData);\n if (!refreshToken) return { status: \"unauthenticated\", accessToken: undefined };\n\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const idToken = sessionData.tokens?.idToken;\n const refreshResult = await getRefreshResult({\n refreshFlights,\n refreshToken,\n idToken,\n origin,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs: defaultTimeoutInMs,\n });\n\n if (refreshResult.type === \"success\") {\n const refreshedAccessToken = refreshResult.tokens.accessToken;\n if (!refreshedAccessToken) {\n return { status: \"transient\", accessToken: undefined };\n }\n await writeTokens(sessionManager, refreshResult.tokens);\n return {\n status: \"authenticated\",\n accessToken: refreshedAccessToken,\n };\n }\n\n if (refreshResult.type === \"invalid\") {\n await clearTokens(sessionManager);\n return { status: \"unauthenticated\", accessToken: undefined };\n }\n\n return { status: \"transient\", accessToken: undefined };\n }\n\n async function prepareLoginUrl(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: PrepareLoginUrlOptions,\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const state = generateRandomBase64Url();\n const nonce = generateRandomBase64Url();\n const codeVerifier = generateRandomBase64Url();\n const codeChallenge = await createCodeChallenge(codeVerifier);\n const returnTo = sanitizeReturnTo(options.returnTo, origin);\n const createdAt = Date.now();\n\n const sessionData = await readSessionData(sessionManager);\n await writeSessionData(sessionManager, {\n ...sessionData,\n pendingLogin: { state, nonce, codeVerifier, returnTo, origin, createdAt },\n });\n\n const loginUrl = new URL(endpoints.authorizeUrl);\n loginUrl.searchParams.set(\"client_id\", customerAccountApiClientId);\n loginUrl.searchParams.set(\"scope\", CUSTOMER_ACCOUNT_SCOPE);\n loginUrl.searchParams.set(\"response_type\", \"code\");\n loginUrl.searchParams.set(\"redirect_uri\", `${origin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`);\n loginUrl.searchParams.set(\"state\", state);\n loginUrl.searchParams.set(\"nonce\", nonce);\n loginUrl.searchParams.set(\"code_challenge\", codeChallenge);\n loginUrl.searchParams.set(\"code_challenge_method\", CODE_CHALLENGE_METHOD);\n setOptionalLoginParams(loginUrl, options);\n\n return loginUrl.toString();\n }\n\n async function handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ) {\n return (await handleOAuthCallbackResult(sessionManager, requestContext, request)).location;\n }\n\n async function handleOAuthCallbackResult(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<OAuthCallbackResult> {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n try {\n return await completeOAuthCallback({\n sessionManager,\n request,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs: defaultTimeoutInMs,\n });\n } catch (error) {\n await clearPendingLogin(sessionManager);\n throw error;\n }\n }\n\n async function logout(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: LogoutOptions = {},\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const sessionData = await readSessionData(sessionManager);\n const idToken = sessionData.tokens?.idToken;\n const postLogoutRedirectUri = absoluteSameOriginUrl(\n options.postLogoutRedirectUri ?? origin,\n origin,\n );\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n\n if (!idToken) return postLogoutRedirectUri;\n\n const logoutUrl = new URL(endpoints.logoutUrl);\n logoutUrl.searchParams.set(\"id_token_hint\", idToken);\n logoutUrl.searchParams.set(\"post_logout_redirect_uri\", postLogoutRedirectUri);\n return logoutUrl.toString();\n }\n\n const customerSession: CustomerSessionWithInternals = {\n [CUSTOMER_SESSION_INTERNAL_BRAND]: true,\n isLoggedIn: async (sessionManager, requestContext) => {\n requestContext.markResponseAsPersonalized(\n CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON,\n );\n return hasCustomerSession(await readSessionData(sessionManager));\n },\n getAccessToken,\n getOrRefreshAccessToken,\n prepareLoginUrl,\n handleOAuthCallback,\n logout,\n };\n customerSessionInternals.set(customerSession, {\n getOrRefreshAccessToken: getOrRefreshAccessTokenResult,\n handleOAuthCallback: handleOAuthCallbackResult,\n });\n return customerSession;\n}\n\nexport async function getCustomerSessionRefreshResult(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n): Promise<TokenRefreshResult | undefined> {\n return customerSessionInternals\n .get(customerSession)\n ?.getOrRefreshAccessToken(sessionManager, requestContext, options);\n}\n\n/**\n * Creates Customer Account server handlers (`/account/login`, `/account/authorize`,\n * `/account/refresh`, `/account/logout`) for `handleShopifyRoutes`. The handlers\n * return redirect results: `login` redirects to Shopify's OAuth, `logout` redirects\n * to Shopify's logout endpoint when an `id_token` exists, and `authorize`/`refresh`\n * redirect back to the app (same-origin `return_to`). Invoke these paths via\n * full-page navigation (plain `<a>`/`<form>`), not a framework client-side\n * navigation component — client-nav cannot follow these raw redirects.\n */\nexport function createCustomerAccountServerHandlers<\n const TOptions extends CreateCustomerAccountServerHandlersOptions,\n>(options: TOptions): CustomerAccountServerHandlersForOptions<TOptions>;\nexport function createCustomerAccountServerHandlers(\n options: CreateCustomerAccountServerHandlersOptions,\n): CustomerAccountServerHandlers | CustomerAccountServerHandlersWithCartSync {\n const {\n customerSession,\n defaultPostLoginRedirectPathname = DEFAULT_POST_LOGIN_REDIRECT_PATHNAME,\n loginFailedRedirectPath = FAILED_LOGIN_PATH,\n postLogoutRedirectUri = DEFAULT_POST_LOGOUT_REDIRECT_URI,\n } = options;\n const { origin: originOption } = options;\n const cartSync = resolveCartBuyerIdentitySync(options);\n\n return {\n authorize: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_AUTHORIZE_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleAuthorizeRoute(\n customerSession,\n context,\n loginFailedRedirectPath,\n originOption,\n cartSync,\n );\n },\n ),\n login: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_LOGIN_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n const { request, sessionManager, requestContext } = context;\n return handleLoginRoute(\n customerSession,\n sessionManager,\n requestContext,\n request,\n defaultPostLoginRedirectPathname,\n originOption,\n );\n },\n ),\n logout: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_LOGOUT_PATH,\n \"POST\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleLogoutRoute(\n customerSession,\n context,\n postLogoutRedirectUri,\n originOption,\n cartSync,\n );\n },\n ),\n refresh: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_REFRESH_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleRefreshRoute(customerSession, context, originOption, cartSync);\n },\n ),\n };\n}\n\nfunction resolveCartBuyerIdentitySync(\n options: CreateCustomerAccountServerHandlersOptions,\n): CartBuyerIdentitySync | undefined {\n if (!options.cartServerHandlers) return undefined;\n\n const cartSync = getCartBuyerIdentitySync(options.cartServerHandlers);\n if (!cartSync) {\n throw new Error(\n \"cartServerHandlers must be created by createCartServerHandlers with the customerSession option.\",\n );\n }\n if (!customerSessionInternals.has(options.customerSession)) {\n throw new Error(\n \"Cart buyer identity sync requires the customerSession returned by createCustomerSession.\",\n );\n }\n return cartSync;\n}\n\nasync function handleLoginRoute(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n defaultPostLoginRedirectPathname: string,\n originOption: string | ((request: Request) => string) | undefined,\n): Promise<CustomerAccountRouteResult> {\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n const requestUrl = new URL(request.url);\n const defaultReturnTo = sanitizeReturnTo(\n defaultPostLoginRedirectPathname,\n origin,\n DEFAULT_POST_LOGIN_REDIRECT_PATHNAME,\n );\n const requestedReturnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n const returnTo = sanitizeReturnTo(requestedReturnTo, origin, defaultReturnTo);\n const loginUrl = await customerSession.prepareLoginUrl(sessionManager, requestContext, {\n origin,\n returnTo,\n countryCode: requestContext.i18n.country,\n locale: getOptionalSearchParam(requestUrl, \"locale\"),\n acrValues: getOptionalSearchParam(requestUrl, \"acr_values\"),\n loginHint: getOptionalSearchParam(requestUrl, \"login_hint\"),\n loginHintMode: getOptionalSearchParam(requestUrl, \"login_hint_mode\"),\n });\n return redirectResult(loginUrl, await commitSession(sessionManager));\n}\n\nasync function handleLogoutRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n postLogoutRedirectUri: string,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n if (!isSameOriginPost(request, origin)) return forbiddenResult();\n\n const requestUrl = new URL(request.url);\n const requestedReturnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n const logoutUrl = await customerSession.logout(sessionManager, requestContext, {\n origin,\n postLogoutRedirectUri: sanitizeReturnTo(requestedReturnTo, origin, postLogoutRedirectUri),\n });\n\n // The redirect must complete even when detach fails: skipping it would leave\n // the Shopify IdP session alive after the app session is already destroyed.\n // Expiring the cart cookie is the fail-safe when the cart keeps the identity.\n let detachFailed = false;\n if (cartSync) {\n detachFailed = !(await syncCartBuyerIdentity(cartSync, context, null, \"logout\"));\n }\n const headers = new Headers(await commitSession(sessionManager));\n if (cartSync && detachFailed) headers.append(\"set-cookie\", cartSync.expiredCartCookie);\n return redirectResult(logoutUrl, headers);\n}\n\nasync function handleAuthorizeRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n loginFailedRedirectPath: string,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n try {\n if (!cartSync) {\n const location = await customerSession.handleOAuthCallback(\n sessionManager,\n requestContext,\n request,\n );\n return redirectResult(location, await commitSession(sessionManager));\n }\n\n const { location, accessToken } = await handleOAuthCallbackWithAccessToken(\n customerSession,\n sessionManager,\n requestContext,\n request,\n );\n // Best-effort: the customer is authenticated at this point, so a failed\n // cart sync must not turn a successful login into an error response. The\n // next refresh retries the attach.\n await syncCartBuyerIdentity(cartSync, context, accessToken, \"authorize\");\n return redirectResult(location, await commitSession(sessionManager));\n } catch (error) {\n if (!(error instanceof CustomerAccountOAuthError)) throw error;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n return redirectResult(\n sanitizeReturnTo(loginFailedRedirectPath, origin),\n await commitSession(sessionManager),\n );\n }\n}\n\nasync function handleRefreshRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n if (!cartSync) {\n await customerSession.getOrRefreshAccessToken(sessionManager, requestContext, { origin });\n return refreshRedirectResult(request, origin, await commitSession(sessionManager));\n }\n\n const refreshResult = await getOrRefreshAccessTokenWithStatus(\n customerSession,\n sessionManager,\n requestContext,\n { origin },\n );\n // Transient refresh failures keep the refreshable session, so the cart's\n // identity is left untouched; a definitive outcome attaches or detaches it.\n let detachFailed = false;\n if (refreshResult.status !== \"transient\") {\n const customerAccessToken = refreshResult.accessToken ?? null;\n const syncSucceeded = await syncCartBuyerIdentity(\n cartSync,\n context,\n customerAccessToken,\n \"refresh\",\n );\n detachFailed = customerAccessToken === null && !syncSucceeded;\n }\n\n const headers = new Headers(await commitSession(sessionManager));\n if (detachFailed) headers.append(\"set-cookie\", cartSync.expiredCartCookie);\n return refreshRedirectResult(request, origin, headers);\n}\n\nfunction refreshRedirectResult(\n request: Request,\n origin: string,\n headers?: HeadersInit,\n): ShopifyRouteRedirectResult {\n const requestUrl = new URL(request.url);\n const returnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n return redirectResult(sanitizeReturnTo(returnTo, origin), headers);\n}\n\nasync function handleOAuthCallbackWithAccessToken(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n): Promise<OAuthCallbackResult> {\n const internal = customerSessionInternals.get(customerSession);\n if (!internal) {\n throw new Error(\"Customer session was not created by createCustomerSession\");\n }\n return internal.handleOAuthCallback(sessionManager, requestContext, request);\n}\n\nasync function getOrRefreshAccessTokenWithStatus(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions,\n): Promise<TokenRefreshResult> {\n const internal = customerSessionInternals.get(customerSession);\n if (!internal) {\n throw new Error(\"Customer session was not created by createCustomerSession\");\n }\n return internal.getOrRefreshAccessToken(sessionManager, requestContext, options);\n}\n\nasync function syncCartBuyerIdentity(\n cartSync: CartBuyerIdentitySync,\n context: CustomerAccountRuntimeRouteHandlerContext,\n customerAccessToken: string | null,\n route: \"authorize\" | \"refresh\" | \"logout\",\n): Promise<boolean> {\n try {\n if (!context.storefrontClient) {\n throw new Error(\n \"Customer Account handlers configured with cartServerHandlers require storefrontClient.\",\n );\n }\n await cartSync.updateBuyerIdentity(\n { request: context.request, storefrontClient: context.storefrontClient },\n customerAccessToken,\n );\n return true;\n } catch (error) {\n log.error(\"cart buyer identity sync failed\", { route, error });\n return false;\n }\n}\n\nasync function resolveRouteOrigin(\n sessionManager: WritableCustomerSessionManager,\n request: Request,\n originOption: string | ((request: Request) => string) | undefined,\n) {\n if (typeof originOption === \"function\") return normalizeOrigin(originOption(request));\n return getResolvedOrigin(sessionManager, originOption);\n}\n\nasync function getResolvedOrigin(\n sessionManager: WritableCustomerSessionManager,\n originOverride: string | undefined,\n) {\n return normalizeOrigin(originOverride ?? (await sessionManager.getSessionOrigin()));\n}\n\nfunction getOptionalSearchParam(url: URL, name: string): string | undefined {\n return url.searchParams.get(name) ?? undefined;\n}\n\nfunction redirectResult(location: string, headers?: HeadersInit): ShopifyRouteRedirectResult {\n const redirectHeaders = new Headers(headers);\n redirectHeaders.set(\"cache-control\", NO_STORE_CACHE_CONTROL);\n return { type: \"redirect\", location, headers: redirectHeaders };\n}\n\nfunction forbiddenResult(): ShopifyRouteErrorResult {\n return {\n type: \"error\",\n status: FORBIDDEN_STATUS,\n error: { code: FORBIDDEN_ERROR_CODE, message: FORBIDDEN_ERROR_MESSAGE },\n headers: { \"cache-control\": NO_STORE_CACHE_CONTROL },\n };\n}\n\nasync function commitSession(\n sessionManager: WritableCustomerSessionManager,\n): Promise<HeadersInit | undefined> {\n return (await sessionManager.commit?.()) ?? undefined;\n}\n\nasync function completeOAuthCallback({\n sessionManager,\n request,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs,\n}: {\n sessionManager: WritableCustomerSessionManager;\n request: Request;\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<OAuthCallbackResult> {\n const requestUrl = new URL(request.url);\n const code = requestUrl.searchParams.get(\"code\");\n const state = requestUrl.searchParams.get(\"state\");\n const sessionData = await readSessionData(sessionManager);\n const pendingLogin = getPendingLogin(sessionData);\n\n assertOAuthCallbackParams(code, state, pendingLogin);\n\n const origin = pendingLogin.origin ?? getOriginFromRequest(request);\n const tokenResponse = await exchangeAuthorizationCode({\n endpoints,\n customerAccountApiClientId,\n origin,\n code,\n codeVerifier: pendingLogin.codeVerifier,\n fetch,\n timeoutInMs,\n });\n validateIdTokenClaims(tokenResponse.id_token, {\n audience: customerAccountApiClientId,\n issuer: endpoints.issuer,\n nonce: pendingLogin.nonce,\n });\n\n await writeSessionData(sessionManager, {\n tokens: createTokensFromResponse(tokenResponse),\n });\n\n return {\n location: pendingLogin.returnTo ?? DEFAULT_LOGIN_RETURN_TO_PATH,\n accessToken: tokenResponse.access_token,\n };\n}\n\nfunction isSameOriginPost(request: Request, trustedOrigin: string): boolean {\n const origin = request.headers.get(\"origin\");\n if (origin) {\n try {\n return normalizeOrigin(origin) === trustedOrigin;\n } catch {\n return false;\n }\n }\n\n const referer = request.headers.get(\"referer\");\n if (!referer) return false;\n\n try {\n return normalizeOrigin(new URL(referer).origin) === trustedOrigin;\n } catch {\n return false;\n }\n}\n\nfunction assertOAuthCallbackParams(\n code: string | null,\n state: string | null,\n pendingLogin: Required<PendingLogin>,\n): asserts code is string {\n if (!code || !state) {\n throw new CustomerAccountOAuthError(\n \"missing_callback_params\",\n \"OAuth callback is missing code or state\",\n );\n }\n\n if (pendingLogin.state !== state) {\n throw new CustomerAccountOAuthError(\n \"state_mismatch\",\n \"OAuth callback state does not match session state\",\n );\n }\n}\n\nasync function exchangeAuthorizationCode({\n endpoints,\n customerAccountApiClientId,\n origin,\n code,\n codeVerifier,\n fetch,\n timeoutInMs,\n}: {\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n origin: string;\n code: string;\n codeVerifier: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<Required<ParsedTokenEndpointResponse>> {\n const body = new URLSearchParams({\n grant_type: AUTHORIZATION_CODE_GRANT_TYPE,\n client_id: customerAccountApiClientId,\n redirect_uri: `${origin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`,\n code,\n code_verifier: codeVerifier,\n });\n return withTokenRequestTimeout(timeoutInMs, async (signal) => {\n const response = await postTokenRequest({\n url: endpoints.tokenUrl,\n origin,\n body,\n fetch,\n signal,\n });\n\n if (!response.ok) {\n cancelResponseBody(response);\n const errorCode =\n response.status === 400 || response.status === 401\n ? \"token_exchange_rejected\"\n : \"token_exchange_failed\";\n throw new CustomerAccountOAuthError(\n errorCode,\n `Customer Account OAuth token exchange failed with ${response.status}`,\n );\n }\n\n return parseRequiredTokenResponse(await readTokenResponseJson(response, signal));\n });\n}\n\nasync function getRefreshResult({\n refreshFlights,\n refreshToken,\n idToken,\n origin,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs,\n}: {\n refreshFlights: Map<string, Promise<RefreshResult>>;\n refreshToken: string;\n idToken: string | undefined;\n origin: string;\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}) {\n const flightKey = `${origin}\\n${refreshToken}`;\n const existingFlight = refreshFlights.get(flightKey);\n if (existingFlight) return existingFlight;\n\n const flight = refreshAccessToken({\n endpoints,\n customerAccountApiClientId,\n refreshToken,\n idToken,\n origin,\n fetch,\n timeoutInMs,\n });\n refreshFlights.set(flightKey, flight);\n\n try {\n return await flight;\n } finally {\n refreshFlights.delete(flightKey);\n }\n}\n\nasync function refreshAccessToken({\n endpoints,\n customerAccountApiClientId,\n refreshToken,\n idToken,\n origin,\n fetch,\n timeoutInMs,\n}: {\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n refreshToken: string;\n idToken: string | undefined;\n origin: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<RefreshResult> {\n const body = new URLSearchParams({\n grant_type: REFRESH_TOKEN_GRANT_TYPE,\n client_id: customerAccountApiClientId,\n refresh_token: refreshToken,\n });\n\n try {\n return await withTokenRequestTimeout(timeoutInMs, async (signal) => {\n const response = await postTokenRequest({\n url: endpoints.tokenUrl,\n origin,\n body,\n fetch,\n signal,\n });\n return readRefreshResult(response, refreshToken, idToken, signal);\n });\n } catch {\n return { type: \"transient\" };\n }\n}\n\nasync function readRefreshResult(\n response: Response,\n currentRefreshToken: string,\n currentIdToken: string | undefined,\n signal: AbortSignal,\n): Promise<RefreshResult> {\n if (response.status === 400 || response.status === 401) {\n cancelResponseBody(response);\n return { type: \"invalid\" };\n }\n\n if (!response.ok) {\n cancelResponseBody(response);\n return { type: \"transient\" };\n }\n\n try {\n const tokenResponse = await readTokenResponseJson(response, signal);\n const parsedTokenResponse = parseRefreshTokenResponse(tokenResponse);\n return {\n type: \"success\",\n tokens: createTokensFromResponse({\n ...parsedTokenResponse,\n refresh_token: parsedTokenResponse.refresh_token ?? currentRefreshToken,\n id_token: parsedTokenResponse.id_token ?? currentIdToken,\n }),\n };\n } catch {\n return { type: \"transient\" };\n }\n}\n\nasync function postTokenRequest({\n url,\n origin,\n body,\n fetch,\n signal,\n}: TokenRequestParams): Promise<Response> {\n return await withAbort(\n fetch(url, {\n method: \"POST\",\n headers: new Headers({\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n \"User-Agent\": USER_AGENT,\n Origin: origin,\n }),\n body,\n cache: \"no-store\",\n redirect: \"manual\",\n signal,\n }),\n signal,\n );\n}\n\nasync function withTokenRequestTimeout<T>(\n timeoutInMs: number,\n operation: (signal: AbortSignal) => Promise<T>,\n): Promise<T> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => {\n controller.abort(\n new CustomerAccountApiError(\n `Customer Account OAuth request timed out after ${timeoutInMs}ms`,\n ),\n );\n }, timeoutInMs);\n\n try {\n return await operation(controller.signal);\n } finally {\n clearTimeout(timeoutId);\n }\n}\n\nfunction withAbort<T>(promise: T | Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) return Promise.reject(getAbortReason(signal));\n\n return new Promise<T>((resolve, reject) => {\n const abort = () => {\n signal.removeEventListener(\"abort\", abort);\n reject(getAbortReason(signal));\n };\n signal.addEventListener(\"abort\", abort, { once: true });\n Promise.resolve(promise).then(\n (value) => {\n signal.removeEventListener(\"abort\", abort);\n resolve(value);\n },\n (error) => {\n signal.removeEventListener(\"abort\", abort);\n reject(error);\n },\n );\n });\n}\n\nfunction getAbortReason(signal: AbortSignal): unknown {\n return signal.reason ?? new DOMException(\"signal is aborted without reason\", \"AbortError\");\n}\n\nasync function readTokenResponseJson(\n response: Response,\n signal: AbortSignal,\n): Promise<TokenEndpointResponse> {\n const json = await withAbort(response.json(), signal);\n if (!isObjectRecord(json)) {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response must be an object\",\n );\n }\n return {\n access_token: json.access_token,\n refresh_token: json.refresh_token,\n id_token: json.id_token,\n expires_in: json.expires_in,\n };\n}\n\nfunction parseRequiredTokenResponse(\n response: TokenEndpointResponse,\n): Required<ParsedTokenEndpointResponse> {\n const parsed = parseRefreshTokenResponse(response);\n if (typeof response.refresh_token !== \"string\" || response.refresh_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing refresh_token\",\n );\n }\n\n if (typeof response.id_token !== \"string\" || response.id_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing id_token\",\n );\n }\n return { ...parsed, refresh_token: response.refresh_token, id_token: response.id_token };\n}\n\nfunction parseRefreshTokenResponse(response: TokenEndpointResponse): ParsedTokenEndpointResponse {\n if (typeof response.access_token !== \"string\" || response.access_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing access_token\",\n );\n }\n\n if (\n typeof response.expires_in !== \"number\" ||\n !Number.isFinite(response.expires_in) ||\n response.expires_in <= 0\n ) {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing expires_in\",\n );\n }\n\n return {\n access_token: response.access_token,\n refresh_token:\n typeof response.refresh_token === \"string\" && response.refresh_token !== \"\"\n ? response.refresh_token\n : undefined,\n id_token:\n typeof response.id_token === \"string\" && response.id_token !== \"\"\n ? response.id_token\n : undefined,\n expires_in: response.expires_in,\n };\n}\n\nfunction createTokensFromResponse(response: ParsedTokenEndpointResponse): CustomerAccountTokens {\n return {\n accessToken: response.access_token,\n refreshToken: response.refresh_token,\n idToken: response.id_token,\n expiresAt:\n Date.now() + (response.expires_in - EXPIRY_BUFFER_IN_SECONDS) * MILLISECONDS_PER_SECOND,\n };\n}\n\nfunction validateIdTokenClaims(\n idToken: string,\n expected: { audience: string; issuer: string; nonce: string },\n): void {\n const claims = parseJwtPayload(idToken);\n if (claims.nonce !== expected.nonce) {\n throw new CustomerAccountOAuthError(\n \"nonce_mismatch\",\n \"OAuth id_token nonce does not match session nonce\",\n );\n }\n\n if (claims.iss !== expected.issuer) {\n throw new CustomerAccountOAuthError(\n \"issuer_mismatch\",\n \"OAuth id_token issuer does not match Customer Account issuer\",\n );\n }\n\n if (!hasAudienceClaim(claims.aud, expected.audience)) {\n throw new CustomerAccountOAuthError(\n \"audience_mismatch\",\n \"OAuth id_token audience does not match Customer Account client ID\",\n );\n }\n\n if (\n typeof claims.exp !== \"number\" ||\n claims.exp <= Math.floor(Date.now() / MILLISECONDS_PER_SECOND)\n ) {\n throw new CustomerAccountOAuthError(\"expired_id_token\", \"OAuth id_token is expired\");\n }\n}\n\nfunction parseJwtPayload(idToken: string): Record<string, unknown> {\n try {\n const [, payload] = idToken.split(\".\");\n if (!payload) throw new Error(\"Missing JWT payload\");\n\n const decodedPayload = JSON.parse(base64UrlDecode(payload));\n if (!isObjectRecord(decodedPayload)) throw new Error(\"JWT payload must be an object\");\n\n return decodedPayload;\n } catch (cause) {\n throw new CustomerAccountOAuthError(\n \"invalid_id_token\",\n \"Customer Account OAuth id_token is invalid\",\n { cause },\n );\n }\n}\n\nfunction hasAudienceClaim(audience: unknown, expectedAudience: string): boolean {\n if (audience === expectedAudience) return true;\n if (!Array.isArray(audience)) return false;\n return audience.includes(expectedAudience);\n}\n\nasync function readSessionData(\n sessionManager: ReadonlyCustomerSessionManager,\n): Promise<CustomerAccountSessionData> {\n const value = await sessionManager.getSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n return isCustomerAccountSessionData(value) ? value : {};\n}\n\nasync function writeSessionData(\n sessionManager: WritableCustomerSessionManager,\n sessionData: CustomerAccountSessionData,\n): Promise<void> {\n await sessionManager.setSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY, sessionData);\n}\n\nasync function writeTokens(\n sessionManager: WritableCustomerSessionManager,\n tokens: CustomerAccountTokens,\n): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n await writeSessionData(sessionManager, { ...sessionData, tokens });\n}\n\nasync function clearTokens(sessionManager: WritableCustomerSessionManager): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n const { pendingLogin } = sessionData;\n if (pendingLogin) {\n await writeSessionData(sessionManager, { pendingLogin });\n return;\n }\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n}\n\nasync function clearPendingLogin(sessionManager: WritableCustomerSessionManager): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n const { tokens } = sessionData;\n if (tokens) {\n await writeSessionData(sessionManager, { tokens });\n return;\n }\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n}\n\nfunction getUsableAccessToken(sessionData: CustomerAccountSessionData): string | undefined {\n const tokens = sessionData.tokens;\n if (!isTokenUsable(tokens)) return undefined;\n return tokens.accessToken;\n}\n\nfunction hasCustomerSession(sessionData: CustomerAccountSessionData): boolean {\n return (\n getUsableAccessToken(sessionData) !== undefined || getRefreshToken(sessionData) !== undefined\n );\n}\n\nfunction getRefreshToken(sessionData: CustomerAccountSessionData): string | undefined {\n const refreshToken = sessionData.tokens?.refreshToken;\n return typeof refreshToken === \"string\" && refreshToken !== \"\" ? refreshToken : undefined;\n}\n\nfunction getPendingLogin(sessionData: CustomerAccountSessionData): Required<PendingLogin> {\n const pendingLogin = sessionData.pendingLogin;\n if (isPendingLoginReady(pendingLogin)) return pendingLogin;\n throw new CustomerAccountOAuthError(\n \"missing_pending_login\",\n \"OAuth callback has no pending login state\",\n );\n}\n\nfunction isTokenUsable(\n tokens: CustomerAccountSessionData[\"tokens\"],\n): tokens is Required<CustomerAccountTokens> {\n if (!tokens || typeof tokens.accessToken !== \"string\" || tokens.accessToken === \"\") return false;\n if (typeof tokens.expiresAt !== \"number\") return false;\n return tokens.expiresAt > Date.now();\n}\n\nfunction isPendingLoginReady(\n pendingLogin: PendingLogin | undefined,\n): pendingLogin is Required<PendingLogin> {\n if (!pendingLogin) return false;\n if (!isNonEmptyString(pendingLogin.state)) return false;\n if (!isNonEmptyString(pendingLogin.nonce)) return false;\n if (!isNonEmptyString(pendingLogin.codeVerifier)) return false;\n if (!isNonEmptyString(pendingLogin.returnTo)) return false;\n if (!isNonEmptyString(pendingLogin.origin)) return false;\n if (typeof pendingLogin.createdAt !== \"number\") return false;\n return Date.now() - pendingLogin.createdAt <= PENDING_LOGIN_TTL_IN_MS;\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value !== \"\";\n}\n\nfunction isCustomerAccountSessionData(value: unknown): value is CustomerAccountSessionData {\n if (!isObjectRecord(value) || Array.isArray(value)) return false;\n const tokens = value.tokens;\n const pendingLogin = value.pendingLogin;\n if (tokens !== undefined && (!isObjectRecord(tokens) || Array.isArray(tokens))) return false;\n return (\n pendingLogin === undefined || (isObjectRecord(pendingLogin) && !Array.isArray(pendingLogin))\n );\n}\n\nfunction setOptionalLoginParams(loginUrl: URL, options: PrepareLoginUrlOptions): void {\n if (options.locale) loginUrl.searchParams.set(\"locale\", options.locale);\n if (options.countryCode) loginUrl.searchParams.set(\"region_country\", options.countryCode);\n if (options.acrValues) loginUrl.searchParams.set(\"acr_values\", options.acrValues);\n if (!options.loginHint) return;\n\n loginUrl.searchParams.set(\"login_hint\", options.loginHint);\n if (options.loginHintMode) loginUrl.searchParams.set(\"login_hint_mode\", options.loginHintMode);\n}\n\nfunction sanitizeReturnTo(\n returnTo: string | null | undefined,\n origin: string,\n fallbackReturnTo = DEFAULT_LOGIN_RETURN_TO_PATH,\n): string {\n if (!returnTo) return fallbackReturnTo;\n\n try {\n const url = new URL(returnTo, origin);\n if (url.origin !== origin) return fallbackReturnTo;\n const sanitizedReturnTo = `${url.pathname}${url.search}${url.hash}`;\n if (new TextEncoder().encode(sanitizedReturnTo).byteLength > MAX_RETURN_TO_LENGTH_IN_BYTES) {\n return fallbackReturnTo;\n }\n return sanitizedReturnTo;\n } catch {\n return fallbackReturnTo;\n }\n}\n\nfunction absoluteSameOriginUrl(url: string, origin: string): string {\n const parsedUrl = new URL(url, origin);\n if (parsedUrl.origin !== origin) return origin;\n return parsedUrl.toString();\n}\n\nasync function createCodeChallenge(codeVerifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(codeVerifier));\n return base64UrlEncode(String.fromCharCode(...new Uint8Array(digest)));\n}\n\nfunction generateRandomBase64Url(): string {\n const bytes = new Uint8Array(RANDOM_BYTES_LENGTH);\n crypto.getRandomValues(bytes);\n return base64UrlEncode(String.fromCharCode(...bytes));\n}\n\nfunction base64UrlEncode(value: string): string {\n return btoa(value).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=/g, \"\");\n}\n\nfunction base64UrlDecode(value: string): string {\n const base64 = value.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const paddingLength = (4 - (base64.length % 4)) % 4;\n return atob(base64.padEnd(base64.length + paddingLength, \"=\"));\n}\n\ntype CustomerAccountEndpoints = {\n authorizeUrl: string;\n tokenUrl: string;\n logoutUrl: string;\n issuer: string;\n};\n\nfunction createCustomerAccountEndpoints(\n shopId: string,\n customerAccountApiUrl: string | undefined,\n): CustomerAccountEndpoints {\n const authBaseUrl = normalizeAuthBaseUrl(\n customerAccountApiUrl ?? `https://shopify.com/authentication/${shopId}`,\n );\n return {\n authorizeUrl: `${authBaseUrl}/oauth/authorize`,\n tokenUrl: `${authBaseUrl}/oauth/token`,\n logoutUrl: `${authBaseUrl}/logout`,\n issuer: authBaseUrl,\n };\n}\n\nfunction normalizeAuthBaseUrl(customerAccountApiUrl: string): string {\n const url = new URL(customerAccountApiUrl);\n if (url.protocol !== \"https:\") {\n throw new Error(\"customerAccountApiUrl must use HTTPS\");\n }\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getOriginFromRequest(request: Request): string {\n return normalizeOrigin(new URL(request.url).origin);\n}\n\nfunction normalizeOrigin(origin: string): string {\n const url = new URL(origin);\n if (url.protocol === \"https:\") return url.origin;\n throw new Error(\n \"Customer Account OAuth origin must use HTTPS. Use a public HTTPS tunnel for local Customer Account login.\",\n );\n}\n\nfunction cancelResponseBody(response: Response): void {\n try {\n void response.body?.cancel().catch(() => undefined);\n } catch {}\n}\n\nfunction validateShopId(shopId: string): void {\n if (!SHOP_ID_RE.test(shopId)) {\n throw new Error(\"shopId must be a numeric Shopify shop ID string\");\n }\n}\n\nfunction validateCustomerAccountApiClientId(customerAccountApiClientId: string): void {\n if (typeof customerAccountApiClientId !== \"string\" || customerAccountApiClientId.trim() === \"\") {\n throw new Error(\"customerAccountApiClientId is required\");\n }\n}\n\nfunction validateTimeout(timeoutInMs: number): void {\n if (\n !Number.isSafeInteger(timeoutInMs) ||\n timeoutInMs <= 0 ||\n timeoutInMs > MAX_SET_TIMEOUT_IN_MS\n ) {\n throw new Error(\n `defaultTimeoutInMs must be a positive safe integer no greater than ${MAX_SET_TIMEOUT_IN_MS}`,\n );\n }\n}\n\nfunction isObjectRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":";;;;AAoBA,MAAM,MAAM,UAAU,mBAAmB;AAEzC,MAAa,kCAAkC,uBAAuB;AACtE,MAAa,8BAA8B,uBAAuB;AAClE,MAAa,+BAA+B,uBAAuB;AACnE,MAAa,gCAAgC,uBAAuB;AAiBpE,MAAM,+BAA+B;AACrC,MAAM,qBAAqB;AAC3B,MAAM,0BAA0B;AAChC,MAAM,0BACJ,+BAA+B,qBAAqB;AAuNtD,MAAM,2CAA2B,IAAI,SAAoD;AA8NzF,eAAsB,gCACpB,iBACA,gBACA,gBACA,UAAgC,EAAE,EACO;AACzC,QAAO,yBACJ,IAAI,gBAAgB,EACnB,wBAAwB,gBAAgB,gBAAgB,QAAQ"}
|
|
1
|
+
{"version":3,"file":"session.mjs","names":[],"sources":["../../../src/customer-account/session.ts"],"sourcesContent":["import type { StorefrontClient } from \"../client\";\nimport {\n getCartBuyerIdentitySync,\n type CartBuyerIdentitySync,\n type CartBuyerIdentitySyncSource,\n} from \"../core/cart/buyer-identity-sync\";\nimport { DEFAULT_TIMEOUT_IN_MS } from \"../core/constants\";\nimport { getLogger } from \"../core/logging\";\nimport type { ShopifyRequestContext } from \"../core/request-context\";\nimport {\n createCallableRouteHandler,\n type CallableRouteHandler,\n type ShopifyRouteErrorResult,\n type ShopifyRouteHandlerContext,\n type ShopifyRouteRedirectResult,\n type ShopifyRouteSessionManager,\n} from \"../core/request-routing/registered-routes\";\nimport { CUSTOMER_ACCOUNT_PATHS, getSameOriginPath } from \"../core/url\";\nimport { CustomerAccountApiError, CustomerAccountOAuthError } from \"./errors\";\n\nconst log = getLogger(\"customer-account\");\n\n/** OAuth callback route path: `\"/account/authorize\"`. */\nexport const CUSTOMER_ACCOUNT_AUTHORIZE_PATH = CUSTOMER_ACCOUNT_PATHS.authorize;\n/** Login route path: `\"/account/login\"`. */\nexport const CUSTOMER_ACCOUNT_LOGIN_PATH = CUSTOMER_ACCOUNT_PATHS.login;\n/** Logout route path: `\"/account/logout\"`. */\nexport const CUSTOMER_ACCOUNT_LOGOUT_PATH = CUSTOMER_ACCOUNT_PATHS.logout;\n/** Token refresh route path: `\"/account/refresh\"`. */\nexport const CUSTOMER_ACCOUNT_REFRESH_PATH = CUSTOMER_ACCOUNT_PATHS.refresh;\n\nconst CUSTOMER_ACCOUNT_SESSION_KEY = \"customerAccount\";\nconst DEFAULT_LOGIN_RETURN_TO_PATH = \"/account\";\nconst DEFAULT_POST_LOGIN_REDIRECT_PATHNAME = \"/\";\nconst DEFAULT_POST_LOGOUT_REDIRECT_URI = \"/\";\nconst FAILED_LOGIN_PATH = \"/account?login=failed\";\nconst FORBIDDEN_STATUS = 403;\nconst FORBIDDEN_ERROR_CODE = \"forbidden\";\nconst FORBIDDEN_ERROR_MESSAGE = \"Forbidden\";\nconst NO_STORE_CACHE_CONTROL = \"no-store\";\nconst AUTHORIZATION_CODE_GRANT_TYPE = \"authorization_code\";\nconst REFRESH_TOKEN_GRANT_TYPE = \"refresh_token\";\nconst CUSTOMER_ACCOUNT_SCOPE = \"openid email customer-account-api:full\";\nconst CODE_CHALLENGE_METHOD = \"S256\";\nconst USER_AGENT = `Hydrogen ${__HYDROGEN_VERSION__}`;\nconst EXPIRY_BUFFER_IN_SECONDS = 120;\nconst PENDING_LOGIN_TTL_IN_MINUTES = 10;\nconst SECONDS_PER_MINUTE = 60;\nconst MILLISECONDS_PER_SECOND = 1_000;\nconst PENDING_LOGIN_TTL_IN_MS =\n PENDING_LOGIN_TTL_IN_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;\nconst RANDOM_BYTES_LENGTH = 32;\nconst MAX_RETURN_TO_LENGTH_IN_BYTES = 2_048;\nconst MAX_SET_TIMEOUT_IN_MS = 2_147_483_647;\nconst SHOP_ID_RE = /^\\d+$/;\nconst CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON = \"customer-session-access-token\";\nconst CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON = \"customer-session-mutation\";\nconst CUSTOMER_SESSION_INTERNAL_BRAND: unique symbol = Symbol(\"hydrogen.customerSessionInternal\");\n\n/** A value that is either `T` or a `Promise<T>`, used as the return type of session manager methods. */\nexport type Awaitable<T> = T | Promise<T>;\n\n/** Read-only Customer Account session storage for UI state and strict access-token reads. */\nexport type ReadonlyCustomerSessionManager = {\n getSessionItem(key: string): Awaitable<unknown>;\n};\n\n/** Writable Customer Account session storage for OAuth, refresh, and logout response boundaries. */\nexport type WritableCustomerSessionManager = ShopifyRouteSessionManager;\n\ntype CustomerAccountSessionData = {\n tokens?: {\n accessToken?: string;\n refreshToken?: string;\n idToken?: string;\n expiresAt?: number;\n };\n pendingLogin?: {\n state?: string;\n nonce?: string;\n codeVerifier?: string;\n returnTo?: string;\n origin?: string;\n createdAt?: number;\n };\n};\n\n/** Options for {@link createCustomerSession}. */\nexport type CreateCustomerSessionOptions = {\n /** Numeric Shopify shop ID as a string (digits only, e.g. `\"12345\"`). Validated against `/^\\d+$/`. */\n shopId: string;\n /** Customer Account API OAuth client ID. Must be a non-empty string. */\n customerAccountApiClientId: string;\n /**\n * Overrides the default `https://shopify.com/authentication/{shopId}` base URL\n * for OAuth endpoints (authorize, token, logout). Also used as the expected\n * `iss` claim when validating id_tokens — pointing this at a proxy causes\n * `issuer_mismatch` unless the proxy preserves the original issuer. Must use\n * HTTPS; validated at construction.\n */\n customerAccountApiUrl?: string;\n /** Custom fetch implementation. Falls back to `globalThis.fetch` when omitted. */\n fetch?: typeof globalThis.fetch;\n /** Timeout for OAuth token requests in milliseconds. Defaults to 30 000 ms. */\n defaultTimeoutInMs?: number;\n};\n\n/** Options for {@link CustomerSession.prepareLoginUrl}. */\nexport type PrepareLoginUrlOptions = {\n /**\n * Overrides the origin used for the OAuth redirect URI. Must be HTTPS —\n * unlike `createCustomerAccountClient`, `http://localhost` is not accepted.\n * Falls back to the session manager's `getSessionOrigin()`.\n */\n origin?: string;\n /**\n * Same-origin path or URL to redirect back to after login, such as `\"/account/orders\"`.\n * Cross-origin values and values over 2 048 bytes fall back to the default, `\"/account\"`.\n */\n returnTo?: string;\n /** Passed as the `locale` search param on the Shopify OAuth authorize URL. */\n locale?: string;\n /** Passed as the `region_country` search param on the Shopify OAuth authorize URL. */\n countryCode?: string;\n /** Email address to pre-fill on the login screen. Passed as `login_hint`. */\n loginHint?: string;\n /** When set to `\"submit\"` (and `loginHint` is provided), skips the login form and sends the one-time code immediately. Passed as `login_hint_mode`. */\n loginHintMode?: string;\n /** Passed as the `acr_values` search param for authentication context class. */\n acrValues?: string;\n};\n\n/**\n * Origin override accepted by {@link CustomerSession.getOrRefreshAccessToken}\n * and {@link CustomerSession.logout}. Falls back to the session manager's\n * `getSessionOrigin()`.\n */\nexport type RequestOriginOptions = {\n /** HTTPS origin string. */\n origin?: string;\n};\n\n/** Options for {@link CustomerSession.logout}. */\nexport type LogoutOptions = RequestOriginOptions & {\n /**\n * URL to redirect to after Shopify's IdP logout completes.\n * Resolved as an absolute same-origin URL. Defaults to `\"/\"`.\n */\n postLogoutRedirectUri?: string;\n};\n\n/**\n * Core session interface returned by {@link createCustomerSession}. Server-only.\n * All methods require a session manager and request context.\n */\nexport type CustomerSession = {\n /**\n * Read-only signed-in check for UI state. Returns `true` when a usable access\n * token OR a refresh token exists. Does not attempt a refresh. Use for\n * conditional UI rendering, not for authorizing access to Customer Account data.\n */\n isLoggedIn(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ): Promise<boolean>;\n /**\n * Returns only a currently usable access token, or `undefined` when the token\n * is missing or expired. Never refreshes and never writes session state.\n */\n getAccessToken(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ): Promise<string | undefined>;\n /**\n * Returns a usable access token, refreshing from Shopify's token endpoint and\n * writing updated tokens through the writable session manager when needed.\n * Returns `undefined` when no refresh token exists (unauthenticated) or when\n * the refresh fails transiently (the session is preserved for retry). A\n * 400/401 from the token endpoint clears stored tokens. Never throws for\n * refresh failures. Concurrent refresh requests for the same origin+token\n * combination are deduplicated per `createCustomerSession` instance. Use only\n * where the eventual response commits the session manager.\n */\n getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: RequestOriginOptions,\n ): Promise<string | undefined>;\n /**\n * Builds a Shopify OAuth authorize URL with PKCE (S256). Stores pending login\n * state (state, nonce, code verifier, returnTo, origin) in the session. Pending\n * login state expires after 10 minutes. Returns the full authorize URL string.\n */\n prepareLoginUrl(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: PrepareLoginUrlOptions,\n ): Promise<string>;\n /**\n * Completes the OAuth authorization code exchange. Validates `state`, exchanges\n * the code for tokens, validates the `id_token` claims (nonce, issuer, audience,\n * expiry), and stores tokens in the session. Returns the `returnTo` path. Clears\n * pending login state on failure.\n *\n * @throws {CustomerAccountOAuthError} On validation failures (`missing_callback_params`,\n * `state_mismatch`, `missing_pending_login`, `token_exchange_rejected`,\n * `token_exchange_failed`, `invalid_token_response`, or id_token claim mismatches).\n * @throws {CustomerAccountApiError} On token-request timeout.\n */\n handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<string>;\n /**\n * Clears session tokens. When an `id_token` exists, returns the Shopify IdP\n * logout URL (with `id_token_hint` and `post_logout_redirect_uri`). When no\n * `id_token` is stored, returns the `postLogoutRedirectUri` directly (local-only\n * logout, no IdP round-trip).\n */\n logout(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: LogoutOptions,\n ): Promise<string>;\n};\n\n/**\n * Route handler objects returned by {@link createCustomerAccountServerHandlers}.\n * Register these with `handleShopifyRoutes` to serve the Customer Account OAuth flow.\n */\nexport type CustomerAccountServerHandlers<\n TContext extends CustomerAccountRouteHandlerContext = CustomerAccountRouteHandlerContext,\n> = {\n /** GET handler for the OAuth callback path (`/account/authorize`). */\n authorize: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_AUTHORIZE_PATH,\n \"GET\"\n >;\n /** GET handler that redirects to Shopify OAuth (`/account/login`). */\n login: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_LOGIN_PATH,\n \"GET\"\n >;\n /** POST handler for logout (`/account/logout`). Requires a same-origin POST for CSRF protection. */\n logout: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_LOGOUT_PATH,\n \"POST\"\n >;\n /** GET handler that refreshes the access token (`/account/refresh`). */\n refresh: CallableRouteHandler<\n TContext,\n CustomerAccountRouteResult,\n typeof CUSTOMER_ACCOUNT_REFRESH_PATH,\n \"GET\"\n >;\n};\n\n/** Customer Account handlers that also synchronize cart buyer identity, so their context requires `storefrontClient`. */\nexport type CustomerAccountServerHandlersWithCartSync =\n CustomerAccountServerHandlers<ShopifyRouteHandlerContext>;\n\ntype CustomerSessionWithInternals = CustomerSession & {\n readonly [CUSTOMER_SESSION_INTERNAL_BRAND]: true;\n};\n\ntype CreateCustomerAccountServerHandlersBaseOptions<\n TCustomerSession extends CustomerSession = CustomerSession,\n> = {\n /** The session object returned by {@link createCustomerSession}. */\n customerSession: TCustomerSession;\n /** Path to redirect to after a successful login when `return_to` is missing or cross-origin. Defaults to `\"/\"`. */\n defaultPostLoginRedirectPathname?: string;\n /** Same-origin path to redirect to when the OAuth callback throws a `CustomerAccountOAuthError` (other errors propagate). Defaults to `\"/account?login=failed\"`. Cross-origin values fall back to `\"/account\"`. */\n loginFailedRedirectPath?: string;\n /** Static origin string, or a function that resolves the origin per request for dynamic multi-origin setups. */\n origin?: string | ((request: Request) => string);\n /** URL to redirect to after logout. Defaults to `\"/\"`. */\n postLogoutRedirectUri?: string;\n};\n\n/** Options for {@link createCustomerAccountServerHandlers}. */\nexport type CreateCustomerAccountServerHandlersOptions =\n | (CreateCustomerAccountServerHandlersBaseOptions & { cartServerHandlers?: undefined })\n | (CreateCustomerAccountServerHandlersBaseOptions<CustomerSessionWithInternals> & {\n /**\n * Cart server handlers created with `createCartServerHandlers({customerSession})`.\n * When provided, the authorize, refresh, and logout routes keep the browser\n * cart's buyer identity in step with the customer session: attach on login\n * and refresh, detach on logout. Sync is best-effort — failures are logged\n * and never block the route's redirect; a failed detach during logout\n * expires the cart cookie instead.\n */\n cartServerHandlers: CartBuyerIdentitySyncSource;\n });\n\ntype CustomerAccountTokens = NonNullable<CustomerAccountSessionData[\"tokens\"]>;\ntype PendingLogin = NonNullable<CustomerAccountSessionData[\"pendingLogin\"]>;\ntype TokenEndpointResponse = {\n access_token?: unknown;\n refresh_token?: unknown;\n id_token?: unknown;\n expires_in?: unknown;\n};\ntype ParsedTokenEndpointResponse = {\n access_token: string;\n refresh_token?: string;\n id_token?: string;\n expires_in: number;\n};\ntype RefreshResult =\n | { type: \"success\"; tokens: CustomerAccountTokens }\n | { type: \"invalid\" }\n | { type: \"transient\" };\ntype TokenRefreshResult =\n | {\n /** A usable access token is available. */\n status: \"authenticated\";\n accessToken: string;\n }\n | {\n /** Refresh failed transiently, but the refreshable session remains. */\n status: \"transient\";\n accessToken: undefined;\n }\n | {\n /** No usable or refreshable customer session remains. */\n status: \"unauthenticated\";\n accessToken: undefined;\n };\ntype OAuthCallbackResult = {\n location: string;\n accessToken: string;\n};\ntype CustomerSessionInternals = {\n getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options?: RequestOriginOptions,\n ): Promise<TokenRefreshResult>;\n handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<OAuthCallbackResult>;\n};\nconst customerSessionInternals = new WeakMap<CustomerSession, CustomerSessionInternals>();\ntype CustomerAccountRouteResult = ShopifyRouteRedirectResult | ShopifyRouteErrorResult;\ntype CustomerAccountRouteHandlerContext = {\n request: Request;\n sessionManager: WritableCustomerSessionManager;\n requestContext: ShopifyRequestContext;\n};\ntype CustomerAccountRuntimeRouteHandlerContext = CustomerAccountRouteHandlerContext & {\n storefrontClient?: StorefrontClient;\n};\ntype CustomerAccountServerHandlersForOptions<TOptions> = TOptions extends {\n cartServerHandlers: CartBuyerIdentitySyncSource;\n}\n ? CustomerAccountServerHandlersWithCartSync\n : CustomerAccountServerHandlers;\ntype TokenRequestParams = {\n url: string;\n origin: string;\n body: URLSearchParams;\n fetch: typeof globalThis.fetch;\n signal: AbortSignal;\n};\n\n/**\n * Creates a {@link CustomerSession} that manages the Shopify Customer Account\n * OAuth flow (PKCE, token exchange, refresh, logout). Server-only: throws in a\n * browser context. Validates `shopId`, `customerAccountApiClientId`,\n * `customerAccountApiUrl` (HTTPS), and timeout at construction.\n *\n * @example\n * ```ts\n * import { createCustomerSession } from '@shopify/hydrogen/customer-account';\n *\n * const customerSession = createCustomerSession({\n * shopId: env.SHOP_ID,\n * customerAccountApiClientId: env.CUSTOMER_ACCOUNT_API_CLIENT_ID,\n * });\n * ```\n *\n * @throws {Error} When called in a browser context, when an option fails validation, or when no `fetch` is available.\n */\nexport function createCustomerSession({\n shopId,\n customerAccountApiClientId,\n customerAccountApiUrl,\n fetch: customFetch,\n defaultTimeoutInMs = DEFAULT_TIMEOUT_IN_MS,\n}: CreateCustomerSessionOptions): CustomerSessionWithInternals {\n if (typeof document !== \"undefined\") {\n throw new Error(\n \"Customer Account OAuth sessions cannot be used in a browser context. Use this helper from server or edge routes only.\",\n );\n }\n\n validateShopId(shopId);\n validateCustomerAccountApiClientId(customerAccountApiClientId);\n validateTimeout(defaultTimeoutInMs);\n\n const fetch = customFetch ?? globalThis.fetch;\n if (typeof fetch !== \"function\") {\n throw new Error(\n \"No fetch function available. Pass a fetch option or ensure globalThis.fetch exists.\",\n );\n }\n\n const endpoints = createCustomerAccountEndpoints(shopId, customerAccountApiUrl);\n const refreshFlights = new Map<string, Promise<RefreshResult>>();\n\n async function getAccessToken(\n sessionManager: ReadonlyCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON);\n const accessToken = getUsableAccessToken(await readSessionData(sessionManager));\n return accessToken;\n }\n\n async function getOrRefreshAccessToken(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n ) {\n const result = await getOrRefreshAccessTokenResult(sessionManager, requestContext, options);\n return result.accessToken;\n }\n\n async function getOrRefreshAccessTokenResult(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n ): Promise<TokenRefreshResult> {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON);\n const sessionData = await readSessionData(sessionManager);\n const accessToken = getUsableAccessToken(sessionData);\n if (accessToken) return { status: \"authenticated\", accessToken };\n\n const refreshToken = getRefreshToken(sessionData);\n if (!refreshToken) return { status: \"unauthenticated\", accessToken: undefined };\n\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const idToken = sessionData.tokens?.idToken;\n const refreshResult = await getRefreshResult({\n refreshFlights,\n refreshToken,\n idToken,\n origin,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs: defaultTimeoutInMs,\n });\n\n if (refreshResult.type === \"success\") {\n const refreshedAccessToken = refreshResult.tokens.accessToken;\n if (!refreshedAccessToken) {\n return { status: \"transient\", accessToken: undefined };\n }\n await writeTokens(sessionManager, refreshResult.tokens);\n return {\n status: \"authenticated\",\n accessToken: refreshedAccessToken,\n };\n }\n\n if (refreshResult.type === \"invalid\") {\n await clearTokens(sessionManager);\n return { status: \"unauthenticated\", accessToken: undefined };\n }\n\n return { status: \"transient\", accessToken: undefined };\n }\n\n async function prepareLoginUrl(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: PrepareLoginUrlOptions,\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const state = generateRandomBase64Url();\n const nonce = generateRandomBase64Url();\n const codeVerifier = generateRandomBase64Url();\n const codeChallenge = await createCodeChallenge(codeVerifier);\n const returnTo = sanitizeReturnTo(options.returnTo, origin);\n const createdAt = Date.now();\n\n const sessionData = await readSessionData(sessionManager);\n await writeSessionData(sessionManager, {\n ...sessionData,\n pendingLogin: { state, nonce, codeVerifier, returnTo, origin, createdAt },\n });\n\n const loginUrl = new URL(endpoints.authorizeUrl);\n loginUrl.searchParams.set(\"client_id\", customerAccountApiClientId);\n loginUrl.searchParams.set(\"scope\", CUSTOMER_ACCOUNT_SCOPE);\n loginUrl.searchParams.set(\"response_type\", \"code\");\n loginUrl.searchParams.set(\"redirect_uri\", `${origin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`);\n loginUrl.searchParams.set(\"state\", state);\n loginUrl.searchParams.set(\"nonce\", nonce);\n loginUrl.searchParams.set(\"code_challenge\", codeChallenge);\n loginUrl.searchParams.set(\"code_challenge_method\", CODE_CHALLENGE_METHOD);\n setOptionalLoginParams(loginUrl, options);\n\n return loginUrl.toString();\n }\n\n async function handleOAuthCallback(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ) {\n return (await handleOAuthCallbackResult(sessionManager, requestContext, request)).location;\n }\n\n async function handleOAuthCallbackResult(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n ): Promise<OAuthCallbackResult> {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n try {\n return await completeOAuthCallback({\n sessionManager,\n request,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs: defaultTimeoutInMs,\n });\n } catch (error) {\n await clearPendingLogin(sessionManager);\n throw error;\n }\n }\n\n async function logout(\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: LogoutOptions = {},\n ) {\n requestContext.markResponseAsPersonalized(CUSTOMER_SESSION_MUTATION_PERSONALIZATION_REASON);\n const origin = await getResolvedOrigin(sessionManager, options.origin);\n const sessionData = await readSessionData(sessionManager);\n const idToken = sessionData.tokens?.idToken;\n const postLogoutRedirectUri = absoluteSameOriginUrl(\n options.postLogoutRedirectUri ?? origin,\n origin,\n );\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n\n if (!idToken) return postLogoutRedirectUri;\n\n const logoutUrl = new URL(endpoints.logoutUrl);\n logoutUrl.searchParams.set(\"id_token_hint\", idToken);\n logoutUrl.searchParams.set(\"post_logout_redirect_uri\", postLogoutRedirectUri);\n return logoutUrl.toString();\n }\n\n const customerSession: CustomerSessionWithInternals = {\n [CUSTOMER_SESSION_INTERNAL_BRAND]: true,\n isLoggedIn: async (sessionManager, requestContext) => {\n requestContext.markResponseAsPersonalized(\n CUSTOMER_SESSION_ACCESS_TOKEN_PERSONALIZATION_REASON,\n );\n return hasCustomerSession(await readSessionData(sessionManager));\n },\n getAccessToken,\n getOrRefreshAccessToken,\n prepareLoginUrl,\n handleOAuthCallback,\n logout,\n };\n customerSessionInternals.set(customerSession, {\n getOrRefreshAccessToken: getOrRefreshAccessTokenResult,\n handleOAuthCallback: handleOAuthCallbackResult,\n });\n return customerSession;\n}\n\nexport async function getCustomerSessionRefreshResult(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions = {},\n): Promise<TokenRefreshResult | undefined> {\n return customerSessionInternals\n .get(customerSession)\n ?.getOrRefreshAccessToken(sessionManager, requestContext, options);\n}\n\n/**\n * Creates Customer Account server handlers (`/account/login`, `/account/authorize`,\n * `/account/refresh`, `/account/logout`) for `handleShopifyRoutes`. The handlers\n * return redirect results: `login` redirects to Shopify's OAuth, `logout` redirects\n * to Shopify's logout endpoint when an `id_token` exists, and `authorize`/`refresh`\n * redirect back to the app (same-origin `return_to`). Invoke these paths via\n * full-page navigation (plain `<a>`/`<form>`), not a framework client-side\n * navigation component — client-nav cannot follow these raw redirects.\n */\nexport function createCustomerAccountServerHandlers<\n const TOptions extends CreateCustomerAccountServerHandlersOptions,\n>(options: TOptions): CustomerAccountServerHandlersForOptions<TOptions>;\nexport function createCustomerAccountServerHandlers(\n options: CreateCustomerAccountServerHandlersOptions,\n): CustomerAccountServerHandlers | CustomerAccountServerHandlersWithCartSync {\n const {\n customerSession,\n defaultPostLoginRedirectPathname = DEFAULT_POST_LOGIN_REDIRECT_PATHNAME,\n loginFailedRedirectPath = FAILED_LOGIN_PATH,\n postLogoutRedirectUri = DEFAULT_POST_LOGOUT_REDIRECT_URI,\n } = options;\n const { origin: originOption } = options;\n const cartSync = resolveCartBuyerIdentitySync(options);\n\n return {\n authorize: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_AUTHORIZE_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleAuthorizeRoute(\n customerSession,\n context,\n loginFailedRedirectPath,\n originOption,\n cartSync,\n );\n },\n ),\n login: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_LOGIN_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n const { request, sessionManager, requestContext } = context;\n return handleLoginRoute(\n customerSession,\n sessionManager,\n requestContext,\n request,\n defaultPostLoginRedirectPathname,\n originOption,\n );\n },\n ),\n logout: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_LOGOUT_PATH,\n \"POST\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleLogoutRoute(\n customerSession,\n context,\n postLogoutRedirectUri,\n originOption,\n cartSync,\n );\n },\n ),\n refresh: createCallableRouteHandler(\n CUSTOMER_ACCOUNT_REFRESH_PATH,\n \"GET\",\n async (context: CustomerAccountRuntimeRouteHandlerContext) => {\n return handleRefreshRoute(customerSession, context, originOption, cartSync);\n },\n ),\n };\n}\n\nfunction resolveCartBuyerIdentitySync(\n options: CreateCustomerAccountServerHandlersOptions,\n): CartBuyerIdentitySync | undefined {\n if (!options.cartServerHandlers) return undefined;\n\n const cartSync = getCartBuyerIdentitySync(options.cartServerHandlers);\n if (!cartSync) {\n throw new Error(\n \"cartServerHandlers must be created by createCartServerHandlers with the customerSession option.\",\n );\n }\n if (!customerSessionInternals.has(options.customerSession)) {\n throw new Error(\n \"Cart buyer identity sync requires the customerSession returned by createCustomerSession.\",\n );\n }\n return cartSync;\n}\n\nasync function handleLoginRoute(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n defaultPostLoginRedirectPathname: string,\n originOption: string | ((request: Request) => string) | undefined,\n): Promise<CustomerAccountRouteResult> {\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n const requestUrl = new URL(request.url);\n const defaultReturnTo = sanitizeReturnTo(\n defaultPostLoginRedirectPathname,\n origin,\n DEFAULT_POST_LOGIN_REDIRECT_PATHNAME,\n );\n const requestedReturnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n const returnTo = sanitizeReturnTo(requestedReturnTo, origin, defaultReturnTo);\n const loginUrl = await customerSession.prepareLoginUrl(sessionManager, requestContext, {\n origin,\n returnTo,\n countryCode: requestContext.i18n.country,\n locale: getOptionalSearchParam(requestUrl, \"locale\"),\n acrValues: getOptionalSearchParam(requestUrl, \"acr_values\"),\n loginHint: getOptionalSearchParam(requestUrl, \"login_hint\"),\n loginHintMode: getOptionalSearchParam(requestUrl, \"login_hint_mode\"),\n });\n return redirectResult(loginUrl, await commitSession(sessionManager));\n}\n\nasync function handleLogoutRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n postLogoutRedirectUri: string,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n if (!isSameOriginPost(request, origin)) return forbiddenResult();\n\n const requestUrl = new URL(request.url);\n const requestedReturnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n const logoutUrl = await customerSession.logout(sessionManager, requestContext, {\n origin,\n postLogoutRedirectUri: sanitizeReturnTo(requestedReturnTo, origin, postLogoutRedirectUri),\n });\n\n // The redirect must complete even when detach fails: skipping it would leave\n // the Shopify IdP session alive after the app session is already destroyed.\n // Expiring the cart cookie is the fail-safe when the cart keeps the identity.\n let detachFailed = false;\n if (cartSync) {\n detachFailed = !(await syncCartBuyerIdentity(cartSync, context, null, \"logout\"));\n }\n const headers = new Headers(await commitSession(sessionManager));\n if (cartSync && detachFailed) headers.append(\"set-cookie\", cartSync.expiredCartCookie);\n return redirectResult(logoutUrl, headers);\n}\n\nasync function handleAuthorizeRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n loginFailedRedirectPath: string,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n try {\n if (!cartSync) {\n const location = await customerSession.handleOAuthCallback(\n sessionManager,\n requestContext,\n request,\n );\n return redirectResult(location, await commitSession(sessionManager));\n }\n\n const { location, accessToken } = await handleOAuthCallbackWithAccessToken(\n customerSession,\n sessionManager,\n requestContext,\n request,\n );\n // Best-effort: the customer is authenticated at this point, so a failed\n // cart sync must not turn a successful login into an error response. The\n // next refresh retries the attach.\n await syncCartBuyerIdentity(cartSync, context, accessToken, \"authorize\");\n return redirectResult(location, await commitSession(sessionManager));\n } catch (error) {\n if (!(error instanceof CustomerAccountOAuthError)) throw error;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n return redirectResult(\n sanitizeReturnTo(loginFailedRedirectPath, origin),\n await commitSession(sessionManager),\n );\n }\n}\n\nasync function handleRefreshRoute(\n customerSession: CustomerSession,\n context: CustomerAccountRuntimeRouteHandlerContext,\n originOption: string | ((request: Request) => string) | undefined,\n cartSync: CartBuyerIdentitySync | undefined,\n): Promise<CustomerAccountRouteResult> {\n const { request, sessionManager, requestContext } = context;\n const origin = await resolveRouteOrigin(sessionManager, request, originOption);\n if (!cartSync) {\n await customerSession.getOrRefreshAccessToken(sessionManager, requestContext, { origin });\n return refreshRedirectResult(request, origin, await commitSession(sessionManager));\n }\n\n const refreshResult = await getOrRefreshAccessTokenWithStatus(\n customerSession,\n sessionManager,\n requestContext,\n { origin },\n );\n // Transient refresh failures keep the refreshable session, so the cart's\n // identity is left untouched; a definitive outcome attaches or detaches it.\n let detachFailed = false;\n if (refreshResult.status !== \"transient\") {\n const customerAccessToken = refreshResult.accessToken ?? null;\n const syncSucceeded = await syncCartBuyerIdentity(\n cartSync,\n context,\n customerAccessToken,\n \"refresh\",\n );\n detachFailed = customerAccessToken === null && !syncSucceeded;\n }\n\n const headers = new Headers(await commitSession(sessionManager));\n if (detachFailed) headers.append(\"set-cookie\", cartSync.expiredCartCookie);\n return refreshRedirectResult(request, origin, headers);\n}\n\nfunction refreshRedirectResult(\n request: Request,\n origin: string,\n headers?: HeadersInit,\n): ShopifyRouteRedirectResult {\n const requestUrl = new URL(request.url);\n const returnTo =\n requestUrl.searchParams.get(\"return_to\") ?? requestUrl.searchParams.get(\"returnTo\");\n return redirectResult(sanitizeReturnTo(returnTo, origin), headers);\n}\n\nasync function handleOAuthCallbackWithAccessToken(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n request: Request,\n): Promise<OAuthCallbackResult> {\n const internal = customerSessionInternals.get(customerSession);\n if (!internal) {\n throw new Error(\"Customer session was not created by createCustomerSession\");\n }\n return internal.handleOAuthCallback(sessionManager, requestContext, request);\n}\n\nasync function getOrRefreshAccessTokenWithStatus(\n customerSession: CustomerSession,\n sessionManager: WritableCustomerSessionManager,\n requestContext: ShopifyRequestContext,\n options: RequestOriginOptions,\n): Promise<TokenRefreshResult> {\n const internal = customerSessionInternals.get(customerSession);\n if (!internal) {\n throw new Error(\"Customer session was not created by createCustomerSession\");\n }\n return internal.getOrRefreshAccessToken(sessionManager, requestContext, options);\n}\n\nasync function syncCartBuyerIdentity(\n cartSync: CartBuyerIdentitySync,\n context: CustomerAccountRuntimeRouteHandlerContext,\n customerAccessToken: string | null,\n route: \"authorize\" | \"refresh\" | \"logout\",\n): Promise<boolean> {\n try {\n if (!context.storefrontClient) {\n throw new Error(\n \"Customer Account handlers configured with cartServerHandlers require storefrontClient.\",\n );\n }\n await cartSync.updateBuyerIdentity(\n { request: context.request, storefrontClient: context.storefrontClient },\n customerAccessToken,\n );\n return true;\n } catch (error) {\n log.error(\"cart buyer identity sync failed\", { route, error });\n return false;\n }\n}\n\nasync function resolveRouteOrigin(\n sessionManager: WritableCustomerSessionManager,\n request: Request,\n originOption: string | ((request: Request) => string) | undefined,\n) {\n if (typeof originOption === \"function\") return normalizeOrigin(originOption(request));\n return getResolvedOrigin(sessionManager, originOption);\n}\n\nasync function getResolvedOrigin(\n sessionManager: WritableCustomerSessionManager,\n originOverride: string | undefined,\n) {\n return normalizeOrigin(originOverride ?? (await sessionManager.getSessionOrigin()));\n}\n\nfunction getOptionalSearchParam(url: URL, name: string): string | undefined {\n return url.searchParams.get(name) ?? undefined;\n}\n\nfunction redirectResult(location: string, headers?: HeadersInit): ShopifyRouteRedirectResult {\n const redirectHeaders = new Headers(headers);\n redirectHeaders.set(\"cache-control\", NO_STORE_CACHE_CONTROL);\n return { type: \"redirect\", location, headers: redirectHeaders };\n}\n\nfunction forbiddenResult(): ShopifyRouteErrorResult {\n return {\n type: \"error\",\n status: FORBIDDEN_STATUS,\n error: { code: FORBIDDEN_ERROR_CODE, message: FORBIDDEN_ERROR_MESSAGE },\n headers: { \"cache-control\": NO_STORE_CACHE_CONTROL },\n };\n}\n\nasync function commitSession(\n sessionManager: WritableCustomerSessionManager,\n): Promise<HeadersInit | undefined> {\n return (await sessionManager.commit?.()) ?? undefined;\n}\n\nasync function completeOAuthCallback({\n sessionManager,\n request,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs,\n}: {\n sessionManager: WritableCustomerSessionManager;\n request: Request;\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<OAuthCallbackResult> {\n const requestUrl = new URL(request.url);\n const code = requestUrl.searchParams.get(\"code\");\n const state = requestUrl.searchParams.get(\"state\");\n const sessionData = await readSessionData(sessionManager);\n const pendingLogin = getPendingLogin(sessionData);\n\n assertOAuthCallbackParams(code, state, pendingLogin);\n\n const origin = pendingLogin.origin ?? getOriginFromRequest(request);\n const tokenResponse = await exchangeAuthorizationCode({\n endpoints,\n customerAccountApiClientId,\n origin,\n code,\n codeVerifier: pendingLogin.codeVerifier,\n fetch,\n timeoutInMs,\n });\n validateIdTokenClaims(tokenResponse.id_token, {\n audience: customerAccountApiClientId,\n issuer: endpoints.issuer,\n nonce: pendingLogin.nonce,\n });\n\n await writeSessionData(sessionManager, {\n tokens: createTokensFromResponse(tokenResponse),\n });\n\n return {\n location: pendingLogin.returnTo ?? DEFAULT_LOGIN_RETURN_TO_PATH,\n accessToken: tokenResponse.access_token,\n };\n}\n\nfunction isSameOriginPost(request: Request, trustedOrigin: string): boolean {\n const origin = request.headers.get(\"origin\");\n if (origin) {\n try {\n return normalizeOrigin(origin) === trustedOrigin;\n } catch {\n return false;\n }\n }\n\n const referer = request.headers.get(\"referer\");\n if (!referer) return false;\n\n try {\n return normalizeOrigin(new URL(referer).origin) === trustedOrigin;\n } catch {\n return false;\n }\n}\n\nfunction assertOAuthCallbackParams(\n code: string | null,\n state: string | null,\n pendingLogin: Required<PendingLogin>,\n): asserts code is string {\n if (!code || !state) {\n throw new CustomerAccountOAuthError(\n \"missing_callback_params\",\n \"OAuth callback is missing code or state\",\n );\n }\n\n if (pendingLogin.state !== state) {\n throw new CustomerAccountOAuthError(\n \"state_mismatch\",\n \"OAuth callback state does not match session state\",\n );\n }\n}\n\nasync function exchangeAuthorizationCode({\n endpoints,\n customerAccountApiClientId,\n origin,\n code,\n codeVerifier,\n fetch,\n timeoutInMs,\n}: {\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n origin: string;\n code: string;\n codeVerifier: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<Required<ParsedTokenEndpointResponse>> {\n const body = new URLSearchParams({\n grant_type: AUTHORIZATION_CODE_GRANT_TYPE,\n client_id: customerAccountApiClientId,\n redirect_uri: `${origin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`,\n code,\n code_verifier: codeVerifier,\n });\n return withTokenRequestTimeout(timeoutInMs, async (signal) => {\n const response = await postTokenRequest({\n url: endpoints.tokenUrl,\n origin,\n body,\n fetch,\n signal,\n });\n\n if (!response.ok) {\n cancelResponseBody(response);\n const errorCode =\n response.status === 400 || response.status === 401\n ? \"token_exchange_rejected\"\n : \"token_exchange_failed\";\n throw new CustomerAccountOAuthError(\n errorCode,\n `Customer Account OAuth token exchange failed with ${response.status}`,\n );\n }\n\n return parseRequiredTokenResponse(await readTokenResponseJson(response, signal));\n });\n}\n\nasync function getRefreshResult({\n refreshFlights,\n refreshToken,\n idToken,\n origin,\n endpoints,\n customerAccountApiClientId,\n fetch,\n timeoutInMs,\n}: {\n refreshFlights: Map<string, Promise<RefreshResult>>;\n refreshToken: string;\n idToken: string | undefined;\n origin: string;\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}) {\n const flightKey = `${origin}\\n${refreshToken}`;\n const existingFlight = refreshFlights.get(flightKey);\n if (existingFlight) return existingFlight;\n\n const flight = refreshAccessToken({\n endpoints,\n customerAccountApiClientId,\n refreshToken,\n idToken,\n origin,\n fetch,\n timeoutInMs,\n });\n refreshFlights.set(flightKey, flight);\n\n try {\n return await flight;\n } finally {\n refreshFlights.delete(flightKey);\n }\n}\n\nasync function refreshAccessToken({\n endpoints,\n customerAccountApiClientId,\n refreshToken,\n idToken,\n origin,\n fetch,\n timeoutInMs,\n}: {\n endpoints: CustomerAccountEndpoints;\n customerAccountApiClientId: string;\n refreshToken: string;\n idToken: string | undefined;\n origin: string;\n fetch: typeof globalThis.fetch;\n timeoutInMs: number;\n}): Promise<RefreshResult> {\n const body = new URLSearchParams({\n grant_type: REFRESH_TOKEN_GRANT_TYPE,\n client_id: customerAccountApiClientId,\n refresh_token: refreshToken,\n });\n\n try {\n return await withTokenRequestTimeout(timeoutInMs, async (signal) => {\n const response = await postTokenRequest({\n url: endpoints.tokenUrl,\n origin,\n body,\n fetch,\n signal,\n });\n return readRefreshResult(response, refreshToken, idToken, signal);\n });\n } catch {\n return { type: \"transient\" };\n }\n}\n\nasync function readRefreshResult(\n response: Response,\n currentRefreshToken: string,\n currentIdToken: string | undefined,\n signal: AbortSignal,\n): Promise<RefreshResult> {\n if (response.status === 400 || response.status === 401) {\n cancelResponseBody(response);\n return { type: \"invalid\" };\n }\n\n if (!response.ok) {\n cancelResponseBody(response);\n return { type: \"transient\" };\n }\n\n try {\n const tokenResponse = await readTokenResponseJson(response, signal);\n const parsedTokenResponse = parseRefreshTokenResponse(tokenResponse);\n return {\n type: \"success\",\n tokens: createTokensFromResponse({\n ...parsedTokenResponse,\n refresh_token: parsedTokenResponse.refresh_token ?? currentRefreshToken,\n id_token: parsedTokenResponse.id_token ?? currentIdToken,\n }),\n };\n } catch {\n return { type: \"transient\" };\n }\n}\n\nasync function postTokenRequest({\n url,\n origin,\n body,\n fetch,\n signal,\n}: TokenRequestParams): Promise<Response> {\n return await withAbort(\n fetch(url, {\n method: \"POST\",\n headers: new Headers({\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n \"User-Agent\": USER_AGENT,\n Origin: origin,\n }),\n body,\n cache: \"no-store\",\n redirect: \"manual\",\n signal,\n }),\n signal,\n );\n}\n\nasync function withTokenRequestTimeout<T>(\n timeoutInMs: number,\n operation: (signal: AbortSignal) => Promise<T>,\n): Promise<T> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => {\n controller.abort(\n new CustomerAccountApiError(\n `Customer Account OAuth request timed out after ${timeoutInMs}ms`,\n ),\n );\n }, timeoutInMs);\n\n try {\n return await operation(controller.signal);\n } finally {\n clearTimeout(timeoutId);\n }\n}\n\nfunction withAbort<T>(promise: T | Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) return Promise.reject(getAbortReason(signal));\n\n return new Promise<T>((resolve, reject) => {\n const abort = () => {\n signal.removeEventListener(\"abort\", abort);\n reject(getAbortReason(signal));\n };\n signal.addEventListener(\"abort\", abort, { once: true });\n Promise.resolve(promise).then(\n (value) => {\n signal.removeEventListener(\"abort\", abort);\n resolve(value);\n },\n (error) => {\n signal.removeEventListener(\"abort\", abort);\n reject(error);\n },\n );\n });\n}\n\nfunction getAbortReason(signal: AbortSignal): unknown {\n return signal.reason ?? new DOMException(\"signal is aborted without reason\", \"AbortError\");\n}\n\nasync function readTokenResponseJson(\n response: Response,\n signal: AbortSignal,\n): Promise<TokenEndpointResponse> {\n const json = await withAbort(response.json(), signal);\n if (!isObjectRecord(json)) {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response must be an object\",\n );\n }\n return {\n access_token: json.access_token,\n refresh_token: json.refresh_token,\n id_token: json.id_token,\n expires_in: json.expires_in,\n };\n}\n\nfunction parseRequiredTokenResponse(\n response: TokenEndpointResponse,\n): Required<ParsedTokenEndpointResponse> {\n const parsed = parseRefreshTokenResponse(response);\n if (typeof response.refresh_token !== \"string\" || response.refresh_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing refresh_token\",\n );\n }\n\n if (typeof response.id_token !== \"string\" || response.id_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing id_token\",\n );\n }\n return { ...parsed, refresh_token: response.refresh_token, id_token: response.id_token };\n}\n\nfunction parseRefreshTokenResponse(response: TokenEndpointResponse): ParsedTokenEndpointResponse {\n if (typeof response.access_token !== \"string\" || response.access_token === \"\") {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing access_token\",\n );\n }\n\n if (\n typeof response.expires_in !== \"number\" ||\n !Number.isFinite(response.expires_in) ||\n response.expires_in <= 0\n ) {\n throw new CustomerAccountOAuthError(\n \"invalid_token_response\",\n \"Customer Account OAuth response is missing expires_in\",\n );\n }\n\n return {\n access_token: response.access_token,\n refresh_token:\n typeof response.refresh_token === \"string\" && response.refresh_token !== \"\"\n ? response.refresh_token\n : undefined,\n id_token:\n typeof response.id_token === \"string\" && response.id_token !== \"\"\n ? response.id_token\n : undefined,\n expires_in: response.expires_in,\n };\n}\n\nfunction createTokensFromResponse(response: ParsedTokenEndpointResponse): CustomerAccountTokens {\n return {\n accessToken: response.access_token,\n refreshToken: response.refresh_token,\n idToken: response.id_token,\n expiresAt:\n Date.now() + (response.expires_in - EXPIRY_BUFFER_IN_SECONDS) * MILLISECONDS_PER_SECOND,\n };\n}\n\nfunction validateIdTokenClaims(\n idToken: string,\n expected: { audience: string; issuer: string; nonce: string },\n): void {\n const claims = parseJwtPayload(idToken);\n if (claims.nonce !== expected.nonce) {\n throw new CustomerAccountOAuthError(\n \"nonce_mismatch\",\n \"OAuth id_token nonce does not match session nonce\",\n );\n }\n\n if (claims.iss !== expected.issuer) {\n throw new CustomerAccountOAuthError(\n \"issuer_mismatch\",\n \"OAuth id_token issuer does not match Customer Account issuer\",\n );\n }\n\n if (!hasAudienceClaim(claims.aud, expected.audience)) {\n throw new CustomerAccountOAuthError(\n \"audience_mismatch\",\n \"OAuth id_token audience does not match Customer Account client ID\",\n );\n }\n\n if (\n typeof claims.exp !== \"number\" ||\n claims.exp <= Math.floor(Date.now() / MILLISECONDS_PER_SECOND)\n ) {\n throw new CustomerAccountOAuthError(\"expired_id_token\", \"OAuth id_token is expired\");\n }\n}\n\nfunction parseJwtPayload(idToken: string): Record<string, unknown> {\n try {\n const [, payload] = idToken.split(\".\");\n if (!payload) throw new Error(\"Missing JWT payload\");\n\n const decodedPayload = JSON.parse(base64UrlDecode(payload));\n if (!isObjectRecord(decodedPayload)) throw new Error(\"JWT payload must be an object\");\n\n return decodedPayload;\n } catch (cause) {\n throw new CustomerAccountOAuthError(\n \"invalid_id_token\",\n \"Customer Account OAuth id_token is invalid\",\n { cause },\n );\n }\n}\n\nfunction hasAudienceClaim(audience: unknown, expectedAudience: string): boolean {\n if (audience === expectedAudience) return true;\n if (!Array.isArray(audience)) return false;\n return audience.includes(expectedAudience);\n}\n\nasync function readSessionData(\n sessionManager: ReadonlyCustomerSessionManager,\n): Promise<CustomerAccountSessionData> {\n const value = await sessionManager.getSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n return isCustomerAccountSessionData(value) ? value : {};\n}\n\nasync function writeSessionData(\n sessionManager: WritableCustomerSessionManager,\n sessionData: CustomerAccountSessionData,\n): Promise<void> {\n await sessionManager.setSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY, sessionData);\n}\n\nasync function writeTokens(\n sessionManager: WritableCustomerSessionManager,\n tokens: CustomerAccountTokens,\n): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n await writeSessionData(sessionManager, { ...sessionData, tokens });\n}\n\nasync function clearTokens(sessionManager: WritableCustomerSessionManager): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n const { pendingLogin } = sessionData;\n if (pendingLogin) {\n await writeSessionData(sessionManager, { pendingLogin });\n return;\n }\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n}\n\nasync function clearPendingLogin(sessionManager: WritableCustomerSessionManager): Promise<void> {\n const sessionData = await readSessionData(sessionManager);\n const { tokens } = sessionData;\n if (tokens) {\n await writeSessionData(sessionManager, { tokens });\n return;\n }\n\n await sessionManager.removeSessionItem(CUSTOMER_ACCOUNT_SESSION_KEY);\n}\n\nfunction getUsableAccessToken(sessionData: CustomerAccountSessionData): string | undefined {\n const tokens = sessionData.tokens;\n if (!isTokenUsable(tokens)) return undefined;\n return tokens.accessToken;\n}\n\nfunction hasCustomerSession(sessionData: CustomerAccountSessionData): boolean {\n return (\n getUsableAccessToken(sessionData) !== undefined || getRefreshToken(sessionData) !== undefined\n );\n}\n\nfunction getRefreshToken(sessionData: CustomerAccountSessionData): string | undefined {\n const refreshToken = sessionData.tokens?.refreshToken;\n return typeof refreshToken === \"string\" && refreshToken !== \"\" ? refreshToken : undefined;\n}\n\nfunction getPendingLogin(sessionData: CustomerAccountSessionData): Required<PendingLogin> {\n const pendingLogin = sessionData.pendingLogin;\n if (isPendingLoginReady(pendingLogin)) return pendingLogin;\n throw new CustomerAccountOAuthError(\n \"missing_pending_login\",\n \"OAuth callback has no pending login state\",\n );\n}\n\nfunction isTokenUsable(\n tokens: CustomerAccountSessionData[\"tokens\"],\n): tokens is Required<CustomerAccountTokens> {\n if (!tokens || typeof tokens.accessToken !== \"string\" || tokens.accessToken === \"\") return false;\n if (typeof tokens.expiresAt !== \"number\") return false;\n return tokens.expiresAt > Date.now();\n}\n\nfunction isPendingLoginReady(\n pendingLogin: PendingLogin | undefined,\n): pendingLogin is Required<PendingLogin> {\n if (!pendingLogin) return false;\n if (!isNonEmptyString(pendingLogin.state)) return false;\n if (!isNonEmptyString(pendingLogin.nonce)) return false;\n if (!isNonEmptyString(pendingLogin.codeVerifier)) return false;\n if (!isNonEmptyString(pendingLogin.returnTo)) return false;\n if (!isNonEmptyString(pendingLogin.origin)) return false;\n if (typeof pendingLogin.createdAt !== \"number\") return false;\n return Date.now() - pendingLogin.createdAt <= PENDING_LOGIN_TTL_IN_MS;\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value !== \"\";\n}\n\nfunction isCustomerAccountSessionData(value: unknown): value is CustomerAccountSessionData {\n if (!isObjectRecord(value) || Array.isArray(value)) return false;\n const tokens = value.tokens;\n const pendingLogin = value.pendingLogin;\n if (tokens !== undefined && (!isObjectRecord(tokens) || Array.isArray(tokens))) return false;\n return (\n pendingLogin === undefined || (isObjectRecord(pendingLogin) && !Array.isArray(pendingLogin))\n );\n}\n\nfunction setOptionalLoginParams(loginUrl: URL, options: PrepareLoginUrlOptions): void {\n if (options.locale) loginUrl.searchParams.set(\"locale\", options.locale);\n if (options.countryCode) loginUrl.searchParams.set(\"region_country\", options.countryCode);\n if (options.acrValues) loginUrl.searchParams.set(\"acr_values\", options.acrValues);\n if (!options.loginHint) return;\n\n loginUrl.searchParams.set(\"login_hint\", options.loginHint);\n if (options.loginHintMode) loginUrl.searchParams.set(\"login_hint_mode\", options.loginHintMode);\n}\n\nfunction sanitizeReturnTo(\n returnTo: string | null | undefined,\n origin: string,\n fallbackReturnTo = DEFAULT_LOGIN_RETURN_TO_PATH,\n): string {\n const path = getSameOriginPath(returnTo, origin);\n if (!path || new TextEncoder().encode(path).byteLength > MAX_RETURN_TO_LENGTH_IN_BYTES) {\n return fallbackReturnTo;\n }\n return path;\n}\n\nfunction absoluteSameOriginUrl(url: string, origin: string): string {\n const parsedUrl = new URL(url, origin);\n if (parsedUrl.origin !== origin) return origin;\n return parsedUrl.toString();\n}\n\nasync function createCodeChallenge(codeVerifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(codeVerifier));\n return base64UrlEncode(String.fromCharCode(...new Uint8Array(digest)));\n}\n\nfunction generateRandomBase64Url(): string {\n const bytes = new Uint8Array(RANDOM_BYTES_LENGTH);\n crypto.getRandomValues(bytes);\n return base64UrlEncode(String.fromCharCode(...bytes));\n}\n\nfunction base64UrlEncode(value: string): string {\n return btoa(value).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=/g, \"\");\n}\n\nfunction base64UrlDecode(value: string): string {\n const base64 = value.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const paddingLength = (4 - (base64.length % 4)) % 4;\n return atob(base64.padEnd(base64.length + paddingLength, \"=\"));\n}\n\ntype CustomerAccountEndpoints = {\n authorizeUrl: string;\n tokenUrl: string;\n logoutUrl: string;\n issuer: string;\n};\n\nfunction createCustomerAccountEndpoints(\n shopId: string,\n customerAccountApiUrl: string | undefined,\n): CustomerAccountEndpoints {\n const authBaseUrl = normalizeAuthBaseUrl(\n customerAccountApiUrl ?? `https://shopify.com/authentication/${shopId}`,\n );\n return {\n authorizeUrl: `${authBaseUrl}/oauth/authorize`,\n tokenUrl: `${authBaseUrl}/oauth/token`,\n logoutUrl: `${authBaseUrl}/logout`,\n issuer: authBaseUrl,\n };\n}\n\nfunction normalizeAuthBaseUrl(customerAccountApiUrl: string): string {\n const url = new URL(customerAccountApiUrl);\n if (url.protocol !== \"https:\") {\n throw new Error(\"customerAccountApiUrl must use HTTPS\");\n }\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getOriginFromRequest(request: Request): string {\n return normalizeOrigin(new URL(request.url).origin);\n}\n\nfunction normalizeOrigin(origin: string): string {\n const url = new URL(origin);\n if (url.protocol === \"https:\") return url.origin;\n throw new Error(\n \"Customer Account OAuth origin must use HTTPS. Use a public HTTPS tunnel for local Customer Account login.\",\n );\n}\n\nfunction cancelResponseBody(response: Response): void {\n try {\n void response.body?.cancel().catch(() => undefined);\n } catch {}\n}\n\nfunction validateShopId(shopId: string): void {\n if (!SHOP_ID_RE.test(shopId)) {\n throw new Error(\"shopId must be a numeric Shopify shop ID string\");\n }\n}\n\nfunction validateCustomerAccountApiClientId(customerAccountApiClientId: string): void {\n if (typeof customerAccountApiClientId !== \"string\" || customerAccountApiClientId.trim() === \"\") {\n throw new Error(\"customerAccountApiClientId is required\");\n }\n}\n\nfunction validateTimeout(timeoutInMs: number): void {\n if (\n !Number.isSafeInteger(timeoutInMs) ||\n timeoutInMs <= 0 ||\n timeoutInMs > MAX_SET_TIMEOUT_IN_MS\n ) {\n throw new Error(\n `defaultTimeoutInMs must be a positive safe integer no greater than ${MAX_SET_TIMEOUT_IN_MS}`,\n );\n }\n}\n\nfunction isObjectRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":";;;;AAoBA,MAAM,MAAM,UAAU,mBAAmB;;AAGzC,MAAa,kCAAkC,uBAAuB;;AAEtE,MAAa,8BAA8B,uBAAuB;;AAElE,MAAa,+BAA+B,uBAAuB;;AAEnE,MAAa,gCAAgC,uBAAuB;AAiBpE,MAAM,+BAA+B;AACrC,MAAM,qBAAqB;AAC3B,MAAM,0BAA0B;AAChC,MAAM,0BACJ,+BAA+B,qBAAqB;AA8StD,MAAM,2CAA2B,IAAI,SAAoD;AAgPzF,eAAsB,gCACpB,iBACA,gBACA,gBACA,UAAgC,EAAE,EACO;AACzC,QAAO,yBACJ,IAAI,gBAAgB,EACnB,wBAAwB,gBAAgB,gBAAgB,QAAQ"}
|
|
@@ -23,18 +23,19 @@ type ShopifyGlobal = {
|
|
|
23
23
|
customerPrivacy: {
|
|
24
24
|
config?: {
|
|
25
25
|
isHeadless?: boolean;
|
|
26
|
+
asyncConsent?: boolean;
|
|
27
|
+
asyncVisitorState?: boolean;
|
|
26
28
|
consentDomain?: string;
|
|
27
29
|
};
|
|
28
|
-
consentStatus?: "
|
|
30
|
+
consentStatus?: "loading" | "loaded";
|
|
29
31
|
currentVisitorConsent: () => Record<string, unknown>;
|
|
30
32
|
preferencesProcessingAllowed: () => boolean;
|
|
31
33
|
saleOfDataAllowed: () => boolean;
|
|
32
34
|
marketingAllowed: () => boolean;
|
|
33
|
-
analyticsProcessingAllowed: () => boolean;
|
|
34
|
-
setTrackingConsent: (consent: Record<string, unknown
|
|
35
|
-
error: string;
|
|
36
|
-
} | undefined) => void) => void | Promise<unknown>;
|
|
35
|
+
analyticsProcessingAllowed: () => boolean; /** Synchronizes consent with Shopify. Await completion before using the updated consent. */
|
|
36
|
+
setTrackingConsent: (consent: Record<string, unknown>) => Promise<unknown>;
|
|
37
37
|
shouldShowBanner: () => boolean;
|
|
38
|
+
shouldShowGDPRBanner: () => boolean;
|
|
38
39
|
};
|
|
39
40
|
locale: Lowercase<I18nConfig["language"]> | string; /** @deprecated Use `Shopify.routes.navigate` instead. */
|
|
40
41
|
navigate?: (url: string) => void | Promise<void>;
|