diff --git a/.env.example b/.env.example deleted file mode 100644 index 9ff0463db..000000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -COMPANY_NAME="Vercel Inc." -TWITTER_CREATOR="@vercel" -TWITTER_SITE="https://nextjs.org/commerce" -SITE_NAME="Next.js Commerce" -SHOPIFY_REVALIDATION_SECRET="" -SHOPIFY_STOREFRONT_ACCESS_TOKEN="" -SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" diff --git a/.gitignore b/.gitignore index 0298027e4..1eee959c7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # vercel .vercel +.local +.upm +.replit +.replit.nix # typescript *.tsbuildinfo diff --git a/lib/shopify/customer/auth-helpers.ts b/lib/shopify/customer/auth-helpers.ts new file mode 100644 index 000000000..3cabe2b16 --- /dev/null +++ b/lib/shopify/customer/auth-helpers.ts @@ -0,0 +1,285 @@ +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; //you need to remain this as type so as not to confuse with the actual function +import { cookies } from 'next/headers'; +import { getNonce } from 'lib/shopify/customer/auth-utils'; +import { + SHOPIFY_CUSTOMER_ACCOUNT_API_URL, + SHOPIFY_USER_AGENT, + SHOPIFY_CLIENT_ID +} from './constants'; + +export async function initialAccessToken( + request: NextRequest, + newOrigin: string, + customerAccountApiUrl: string, + clientId: string +) { + const code = request.nextUrl.searchParams.get('code'); + const state = request.nextUrl.searchParams.get('state'); + /* + STEP 1: Check for all necessary cookies and other information + */ + if (!code) { + console.log('Error: No Code Auth'); + return { success: false, message: `No Code` }; + } + if (!state) { + console.log('Error: No State Auth'); + return { success: false, message: `No State` }; + } + const shopState = request.cookies.get('shop_state'); + const shopStateValue = shopState?.value; + if (!shopStateValue) { + console.log('Error: No Shop State Value'); + return { success: false, message: `No Shop State` }; + } + if (state !== shopStateValue) { + console.log('Error: Shop state mismatch'); + return { success: false, message: `No Shop State Mismatch` }; + } + const codeVerifier = request.cookies.get('shop_verifier'); + const codeVerifierValue = codeVerifier?.value; + if (!codeVerifierValue) { + console.log('No Code Verifier'); + return { success: false, message: `No Code Verifier` }; + } + /* + STEP 2: GET ACCESS TOKEN + */ + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('client_id', clientId); + body.append('redirect_uri', `${newOrigin}/authorize`); + body.append('code', code); + body.append('code_verifier', codeVerifier?.value); + const userAgent = '*'; + const headersNew = new Headers(); + headersNew.append('Content-Type', 'application/x-www-form-urlencoded'); + headersNew.append('User-Agent', userAgent); + headersNew.append('Origin', newOrigin || ''); + const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + const response = await fetch(tokenRequestUrl, { + method: 'POST', + headers: headersNew, + body + }); + const data = await response.json(); + console.log('data initial access token', data); + if (!response.ok) { + console.log('data response error auth', data.error); + console.log('response auth', response.status); + return { success: false, message: `Response error auth` }; + } + if (data?.errors) { + const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth'; + return { success: false, message: `${errorMessage}` }; + } + const nonce = await getNonce(data?.id_token || ''); + const shopNonce = request.cookies.get('shop_nonce'); + const shopNonceValue = shopNonce?.value; + console.log('sent nonce', nonce); + console.log('original nonce', shopNonceValue); + if (nonce !== shopNonceValue) { + //make equal === to force error for testing + console.log('Error nonce match'); + return { success: false, message: `Error: Nonce mismatch` }; + } + return { success: true, data }; +} + +export async function exchangeAccessToken( + token: string, + customerAccountId: string, + customerAccountApiUrl: string, + origin: string +) { + const clientId = customerAccountId; + //this is a constant - see the docs. https://shopify.dev/docs/api/customer#useaccesstoken-propertydetail-audience + const customerApiClientId = '30243aa5-17c1-465a-8493-944bcc4e88aa'; + const accessToken = token; + const body = new URLSearchParams(); + body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'); + body.append('client_id', clientId); + body.append('audience', customerApiClientId); + body.append('subject_token', accessToken); + body.append('subject_token_type', 'urn:ietf:params:oauth:token-type:access_token'); + body.append('scopes', 'https://api.customers.com/auth/customer.graphql'); + + const userAgent = '*'; + + const headers = new Headers(); + headers.append('Content-Type', 'application/x-www-form-urlencoded'); + headers.append('User-Agent', userAgent); + headers.append('Origin', origin); + + // Token Endpoint goes here + const response = await fetch(`${customerAccountApiUrl}/auth/oauth/token`, { + method: 'POST', + headers, + body + }); + + const data = await response.json(); + if (data.error) { + return { success: false, data: data?.error_description }; + } + return { success: true, data }; +} + +export async function refreshToken({ request, origin }: { request: NextRequest; origin: string }) { + const newBody = new URLSearchParams(); + const refreshToken = request.cookies.get('shop_refresh_token'); + const refreshTokenValue = refreshToken?.value; + if (!refreshTokenValue) { + console.log('Error: No Refresh Token'); + return { success: false, message: `no_refresh_token` }; + } + const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; + const clientId = SHOPIFY_CLIENT_ID; + const userAgent = SHOPIFY_USER_AGENT; + newBody.append('grant_type', 'refresh_token'); + newBody.append('refresh_token', refreshTokenValue); + newBody.append('client_id', clientId); + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent, + Origin: origin + }; + const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + const response = await fetch(tokenRequestUrl, { + method: 'POST', + headers, + body: newBody + }); + + if (!response.ok) { + const text = await response.text(); + console.log('response error in refresh token', text); + return { success: false, message: `no_refresh_token` }; + } + const data = await response.json(); + console.log('data response from initial fetch to refresh', data); + const { access_token, expires_in, refresh_token } = data; + + const customerAccessToken = await exchangeAccessToken( + access_token, + clientId, + customerAccountApiUrl, + origin + ); + // console.log("Customer Access Token in refresh request", customerAccessToken) + if (!customerAccessToken.success) { + return { success: false, message: `no_refresh_token` }; + } + + //const expiresAt = new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + '' + //const idToken = id_token + + return { + success: true, + data: { customerAccessToken: customerAccessToken.data.access_token, expires_in, refresh_token } + }; +} + +export async function checkExpires({ + request, + expiresAt, + origin +}: { + request: NextRequest; + expiresAt: string; + origin: string; +}) { + let isExpired = false; + if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) { + isExpired = true; + console.log('Isexpired is true, we are running refresh token!'); + const refresh = await refreshToken({ request, origin }); + console.log('refresh', refresh); + //this will return success: true or success: false - depending on result of refresh + return { ranRefresh: isExpired, refresh }; + } + console.log('is expired is false - just sending back success', isExpired); + return { ranRefresh: isExpired, success: true }; +} + +export function removeAllCookies(response: NextResponseType) { + //response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere. + response.cookies.delete('shop_customer_token'); + response.cookies.delete('shop_refresh_token'); + response.cookies.delete('shop_id_token'); + response.cookies.delete('shop_state'); + response.cookies.delete('shop_nonce'); + response.cookies.delete('shop_verifier'); + response.cookies.delete('shop_expires_at'); + return response; +} + +export async function removeAllCookiesServerAction() { + cookies().delete('shop_customer_token'); + cookies().delete('shop_refresh_token'); + cookies().delete('shop_id_token'); + cookies().delete('shop_state'); + cookies().delete('shop_nonce'); + cookies().delete('shop_verifier'); + cookies().delete('shop_expires_at'); + return { success: true }; +} + +export async function createAllCookies({ + response, + customerAccessToken, + expires_in, + refresh_token, + expiresAt, + id_token +}: { + response: NextResponseType; + customerAccessToken: string; + expires_in: number; + refresh_token: string; + expiresAt: string; + id_token?: string; +}) { + response.cookies.set('shop_customer_token', customerAccessToken, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', //should be lax??? + secure: true, + path: '/', + maxAge: expires_in //value from shopify, seems like this is 2 hours + }); + + //you need to set an expiration here, because otherwise its a sessions cookie + //and will disappear after the user closes the browser and then we can never refresh - same with expires at below + response.cookies.set('shop_refresh_token', refresh_token, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', //should be lax??? + secure: true, + path: '/', + maxAge: 604800 //one week + }); + + //you need to set an expiration here, because otherwise its a sessions cookie + //and will disappear after the user closes the browser and then we can never refresh + response.cookies.set('shop_expires_at', expiresAt, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', //should be lax??? + secure: true, + path: '/', + maxAge: 604800 //one week + }); + + //required for logout - this must be the same as the original expires - it;s long lived so they can logout, otherwise it will expire + //because that's how we got the token, if this is different, it won't work + //we don't always send in id_token here. For example, on refresh it's not available, it's only sent in on the initial authorization + if (id_token) { + response.cookies.set('shop_id_token', id_token, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', //should be lax??? + secure: true, + path: '/', + maxAge: 604800 //one week + }); + } + + return response; +} diff --git a/lib/shopify/customer/auth-utils.ts b/lib/shopify/customer/auth-utils.ts new file mode 100644 index 000000000..63baa8a07 --- /dev/null +++ b/lib/shopify/customer/auth-utils.ts @@ -0,0 +1,48 @@ +// @ts-nocheck +export async function generateCodeVerifier() { + const randomCode = generateRandomCode(); + return base64UrlEncode(randomCode); +} +export async function generateCodeChallenge(codeVerifier: string) { + const digestOp = await crypto.subtle.digest( + { name: 'SHA-256' }, + new TextEncoder().encode(codeVerifier) + ); + const hash = convertBufferToString(digestOp); + return base64UrlEncode(hash); +} +function generateRandomCode() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return String.fromCharCode.apply(null, Array.from(array)); +} +function base64UrlEncode(str: string) { + const base64 = btoa(str); + // This is to ensure that the encoding does not have +, /, or = characters in it. + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} +function convertBufferToString(hash: ArrayBuffer) { + const uintArray = new Uint8Array(hash); + const numberArray = Array.from(uintArray); + return String.fromCharCode(...numberArray); +} + +export async function generateRandomString() { + const timestamp = Date.now().toString(); + const randomString = Math.random().toString(36).substring(2); + return timestamp + randomString; +} + +export async function getNonce(token: string) { + return decodeJwt(token).payload.nonce; +} +function decodeJwt(token: string) { + const [header, payload, signature] = token.split('.'); + const decodedHeader = JSON.parse(atob(header || '')); + const decodedPayload = JSON.parse(atob(payload || '')); + return { + header: decodedHeader, + payload: decodedPayload, + signature + }; +} diff --git a/lib/shopify/customer/constants.ts b/lib/shopify/customer/constants.ts new file mode 100644 index 000000000..3c04d9162 --- /dev/null +++ b/lib/shopify/customer/constants.ts @@ -0,0 +1,10 @@ +export const TAGS = { + customer: 'customer' +}; + +//ENVs +export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || ''; +export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || ''; +export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || ''; +export const SHOPIFY_USER_AGENT = '*'; +export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || ''; diff --git a/lib/shopify/customer/index.ts b/lib/shopify/customer/index.ts new file mode 100644 index 000000000..57873472c --- /dev/null +++ b/lib/shopify/customer/index.ts @@ -0,0 +1,287 @@ +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; +import { NextResponse } from 'next/server'; +//import { revalidateTag } from 'next/cache'; +import { + checkExpires, + removeAllCookies, + initialAccessToken, + exchangeAccessToken, + createAllCookies +} from './auth-helpers'; +import { isShopifyError } from 'lib/type-guards'; +import { parseJSON } from 'lib/shopify/customer/utils/parse-json'; +import { + SHOPIFY_CUSTOMER_ACCOUNT_API_URL, + SHOPIFY_USER_AGENT, + SHOPIFY_CUSTOMER_API_VERSION, + SHOPIFY_CLIENT_ID, + SHOPIFY_ORIGIN +} from './constants'; + +type ExtractVariables = T extends { variables: object } ? T['variables'] : never; +const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; +const apiVersion = SHOPIFY_CUSTOMER_API_VERSION; +const userAgent = SHOPIFY_USER_AGENT; +const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`; + +//NEVER CACHE THIS! Doesn't see to be cached anyway b/c +//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching +//The fetch request comes after the usage of headers or cookies. +//and we always send this anyway after getting a cookie for the customer +export async function shopifyCustomerFetch({ + cache = 'no-store', + customerToken, + query, + tags, + variables +}: { + cache?: RequestCache; + customerToken: string; + query: string; + tags?: string[]; + variables?: ExtractVariables; +}): Promise<{ status: number; body: T } | never> { + try { + const customerOrigin = SHOPIFY_ORIGIN; + const result = await fetch(customerEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + Origin: customerOrigin, + Authorization: customerToken + }, + body: JSON.stringify({ + ...(query && { query }), + ...(variables && { variables }) + }), + cache: 'no-store', //NEVER CACHE THE CUSTOMER REQUEST!!! + ...(tags && { next: { tags } }) + }); + + const body = await result.json(); + + if (!result.ok) { + //the statuses here could be different, a 401 means + //https://shopify.dev/docs/api/customer#endpoints + //401 means the token is bad + console.log('Error in Customer Fetch Status', body.errors); + if (result.status === 401) { + // clear session because current access token is invalid + const errorMessage = 'unauthorized'; + throw errorMessage; //this should throw in the catch below in the non-shopify catch + } + let errors; + try { + errors = parseJSON(body); + } catch (_e) { + errors = [{ message: body }]; + } + throw errors; + } + + //this just throws an error and the error boundary is called + if (body.errors) { + //throw 'Error' + console.log('Error in Customer Fetch', body.errors[0]); + throw body.errors[0]; + } + + return { + status: result.status, + body + }; + } catch (e) { + if (isShopifyError(e)) { + throw { + cause: e.cause?.toString() || 'unknown', + status: e.status || 500, + message: e.message, + query + }; + } + + throw { + error: e, + query + }; + } +} + +export async function isLoggedIn(request: NextRequest, origin: string) { + const customerToken = request.cookies.get('shop_customer_token'); + const customerTokenValue = customerToken?.value; + const refreshToken = request.cookies.get('shop_refresh_token'); + const refreshTokenValue = refreshToken?.value; + const newHeaders = new Headers(request.headers); + if (!customerTokenValue && !refreshTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + } + + const expiresToken = request.cookies.get('shop_expires_at'); + const expiresTokenValue = expiresToken?.value; + if (!expiresTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_expires_at` } + } + const isExpired = await checkExpires({ + request: request, + expiresAt: expiresTokenValue, + origin: origin + }); + console.log('is Expired?', isExpired); + //only execute the code below to reset the cookies if it was expired! + if (isExpired.ranRefresh) { + const isSuccess = isExpired?.refresh?.success; + if (!isSuccess) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_refresh_token` } + } else { + const refreshData = isExpired?.refresh?.data; + //console.log ("refresh data", refreshData) + console.log('We used the refresh token, so now going to reset the token and cookies'); + const newCustomerAccessToken = refreshData?.customerAccessToken; + const expires_in = refreshData?.expires_in; + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`); + const resetCookieResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + return await createAllCookies({ + response: resetCookieResponse, + customerAccessToken: newCustomerAccessToken, + expires_in, + refresh_token: refreshData?.refresh_token, + expiresAt + }); + } + } + + newHeaders.set('x-shop-customer-token', `${customerTokenValue}`); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); +} + +//when we are running on the production website we just get the origin from the request.nextUrl +export function getOrigin(request: NextRequest) { + const nextOrigin = request.nextUrl.origin; + //console.log("Current Origin", nextOrigin) + //when running localhost, we want to use fake origin otherwise we use the real origin + let newOrigin = nextOrigin; + if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') { + newOrigin = SHOPIFY_ORIGIN; + } else { + newOrigin = nextOrigin; + } + return newOrigin; +} + +export async function authorizeFn(request: NextRequest, origin: string) { + const clientId = SHOPIFY_CLIENT_ID; + const newHeaders = new Headers(request.headers); + /*** + STEP 1: Get the initial access token or deny access + ****/ + const dataInitialToken = await initialAccessToken( + request, + origin, + customerAccountApiUrl, + clientId + ); + if (!dataInitialToken.success) { + console.log('Error: Access Denied. Check logs', dataInitialToken.message); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data; + /*** + STEP 2: Get a Customer Access Token + ****/ + const customerAccessToken = await exchangeAccessToken( + access_token, + clientId, + customerAccountApiUrl, + origin || '' + ); + if (!customerAccessToken.success) { + console.log('Error: Customer Access Token'); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + //console.log("customer access Token", customerAccessToken.data.access_token) + /**STEP 3: Set Customer Access Token cookies + We are setting the cookies here b/c if we set it on the request, and then redirect + it doesn't see to set sometimes + **/ + newHeaders.set('x-shop-access', 'allowed'); + /* + const authResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders, + }, + }) + */ + const accountUrl = new URL(`${origin}/account`); + const authResponse = NextResponse.redirect(`${accountUrl}`); + + //sets an expires time 2 minutes before expiration which we can use in refresh strategy + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + + return await createAllCookies({ + response: authResponse, + customerAccessToken: customerAccessToken?.data?.access_token, + expires_in, + refresh_token, + expiresAt, + id_token + }); +} + +export async function logoutFn(request: NextRequest, origin: string) { + //console.log("New Origin", newOrigin) + const idToken = request.cookies.get('shop_id_token'); + const idTokenValue = idToken?.value; + //revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now + + //if there is no idToken, then sending to logout url will redirect shopify, so just + //redirect to login here and delete cookies (presumably they don't even exist) + if (!idTokenValue) { + const logoutUrl = new URL(`${origin}/login`); + const response = NextResponse.redirect(`${logoutUrl}`); + return removeAllCookies(response); + } + + //console.log ("id toke value", idTokenValue) + const logoutUrl = new URL( + `${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}` + ); + //console.log ("logout url", logoutUrl) + const logoutResponse = NextResponse.redirect(logoutUrl); + return removeAllCookies(logoutResponse); +} diff --git a/lib/shopify/customer/queries/customer.ts b/lib/shopify/customer/queries/customer.ts new file mode 100644 index 000000000..a12110115 --- /dev/null +++ b/lib/shopify/customer/queries/customer.ts @@ -0,0 +1,97 @@ +//https://shopify.dev/docs/api/customer/2024-01/queries/customer +export const CUSTOMER_ME_QUERY = /* GraphQL */ ` + query customer { + customer { + emailAddress { + emailAddress + } + firstName + lastName + tags + } + } +`; + +const CUSTOMER_FRAGMENT = `#graphql + fragment OrderCard on Order { + id + number + processedAt + financialStatus + fulfillments(first: 1) { + nodes { + status + } + } + totalPrice { + amount + currencyCode + } + lineItems(first: 2) { + edges { + node { + title + image { + altText + height + url + width + } + } + } + } + } + + fragment AddressPartial on CustomerAddress { + id + formatted + firstName + lastName + company + address1 + address2 + territoryCode + zoneCode + city + zip + phoneNumber + } + + fragment CustomerDetails on Customer { + firstName + lastName + phoneNumber { + phoneNumber + } + emailAddress { + emailAddress + } + defaultAddress { + ...AddressPartial + } + addresses(first: 6) { + edges { + node { + ...AddressPartial + } + } + } + orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { + edges { + node { + ...OrderCard + } + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer +export const CUSTOMER_DETAILS_QUERY = `#graphql + query CustomerDetails { + customer { + ...CustomerDetails + } + } + ${CUSTOMER_FRAGMENT} +` as const; diff --git a/lib/shopify/customer/types.ts b/lib/shopify/customer/types.ts new file mode 100644 index 000000000..2f9915148 --- /dev/null +++ b/lib/shopify/customer/types.ts @@ -0,0 +1,36 @@ +export type Maybe = T | null; + +export type Connection = { + edges: Array>; +}; + +export type Edge = { + node: T; +}; + +export type CustomerData = { + data: { + customer: { + emailAddress: { + emailAddress: string; + }; + firstName: string; + lastName: string; + tags: any[]; + }; + }; +}; + +export type GenericObject = { [key: string]: any }; + +export type CustomerDetailsData = { + data: { + customer: { + emailAddress: { + emailAddress: string; + }; + // Using GenericObject to type 'orders' since the fields are not known in advance + orders: Connection; + }; + }; +}; diff --git a/lib/shopify/customer/utils/parse-json.ts b/lib/shopify/customer/utils/parse-json.ts new file mode 100644 index 000000000..3bf536d16 --- /dev/null +++ b/lib/shopify/customer/utils/parse-json.ts @@ -0,0 +1,7 @@ +export function parseJSON(json: any) { + if (String(json).includes('__proto__')) return JSON.parse(json, noproto); + return JSON.parse(json); +} +function noproto(k: string, v: string) { + if (k !== '__proto__') return v; +} diff --git a/replit.nix b/replit.nix new file mode 100644 index 000000000..5a4647a2c --- /dev/null +++ b/replit.nix @@ -0,0 +1,3 @@ +{pkgs}: { + deps = [ ]; +}