├── web ├── shopify.web.toml ├── frontend │ ├── components │ │ ├── index.ts │ │ ├── providers │ │ │ ├── index.js │ │ │ ├── QueryProvider.jsx │ │ │ ├── PolarisProvider.jsx │ │ │ └── AppBridgeProvider.jsx │ │ └── ProductsCard.tsx │ ├── shopify.web.toml │ ├── prettierrc.json │ ├── assets │ │ ├── home-trophy.png │ │ ├── index.js │ │ └── empty-state.svg │ ├── hooks │ │ ├── index.ts │ │ ├── useAppQuery.ts │ │ └── useAuthenticatedFetch.ts │ ├── index.tsx │ ├── dev_embed.js │ ├── .gitignore │ ├── .eslintrc.cjs │ ├── pages │ │ ├── NotFound.tsx │ │ ├── ExitIframe.tsx │ │ ├── pagename.tsx │ │ └── index.tsx │ ├── README.md │ ├── tsconfig.json │ ├── index.html │ ├── App.tsx │ ├── package.json │ ├── vite.config.js │ └── Routes.tsx ├── environment.ts ├── helpers │ ├── return-top-level-redirection.js │ ├── redirect-to-auth.js │ ├── product-creator.js │ └── ensure-billing.ts ├── app_installations.ts ├── package.json ├── gdpr.ts ├── middleware │ ├── verify-request.ts │ └── auth.ts ├── index.ts └── tsconfig.json ├── .dockerignore ├── .vscode └── extensions.json ├── .npmrc ├── shopify.app.toml ├── Dockerfile ├── package.json ├── .gitignore ├── .all-contributorsrc ├── SECURITY.md └── README.md /web/shopify.web.toml: -------------------------------------------------------------------------------- 1 | type="backend" 2 | 3 | [commands] 4 | dev = "npm run dev" 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | web/node_modules 2 | web/frontend/node_modules 3 | web/frontend/dist 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "shopify.polaris-for-vscode" 4 | ] 5 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | auto-install-peers=true 3 | shamefully-hoist=true 4 | auto-install-peers=true 5 | -------------------------------------------------------------------------------- /shopify.app.toml: -------------------------------------------------------------------------------- 1 | # This file stores configurations for your Shopify app. 2 | 3 | scopes = "write_products" 4 | -------------------------------------------------------------------------------- /web/frontend/components/index.ts: -------------------------------------------------------------------------------- 1 | export { ProductsCard } from "./ProductsCard"; 2 | export * from "./providers"; 3 | -------------------------------------------------------------------------------- /web/frontend/shopify.web.toml: -------------------------------------------------------------------------------- 1 | type="frontend" 2 | 3 | [commands] 4 | dev = "npm run dev" 5 | build = "npm run build" 6 | -------------------------------------------------------------------------------- /web/frontend/prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /web/frontend/assets/home-trophy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiSpencer/shopify-app-template-node-ts/HEAD/web/frontend/assets/home-trophy.png -------------------------------------------------------------------------------- /web/frontend/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useAppQuery } from "./useAppQuery"; 2 | export { useAuthenticatedFetch } from "./useAuthenticatedFetch"; 3 | -------------------------------------------------------------------------------- /web/frontend/assets/index.js: -------------------------------------------------------------------------------- 1 | export { default as notFoundImage } from "./empty-state.svg"; 2 | export { default as trophyImage } from "./home-trophy.png"; 3 | -------------------------------------------------------------------------------- /web/frontend/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | 3 | import App from "./App"; 4 | 5 | ReactDOM.render(, document.getElementById("app")); 6 | -------------------------------------------------------------------------------- /web/frontend/components/providers/index.js: -------------------------------------------------------------------------------- 1 | export { AppBridgeProvider } from "./AppBridgeProvider"; 2 | export { QueryProvider } from "./QueryProvider"; 3 | export { PolarisProvider } from "./PolarisProvider"; 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | ARG SHOPIFY_API_KEY 4 | ENV SHOPIFY_API_KEY=$SHOPIFY_API_KEY 5 | EXPOSE 8081 6 | WORKDIR /app 7 | COPY web . 8 | RUN npm install 9 | RUN cd frontend && npm install && npm run build 10 | CMD ["npm", "run", "serve"] 11 | -------------------------------------------------------------------------------- /web/frontend/dev_embed.js: -------------------------------------------------------------------------------- 1 | import RefreshRuntime from "/@react-refresh"; 2 | 3 | RefreshRuntime.injectIntoGlobalHook(window); 4 | window.$RefreshReg$ = () => {}; 5 | window.$RefreshSig$ = () => (type) => type; 6 | window.__vite_plugin_react_preamble_installed__ = true; 7 | -------------------------------------------------------------------------------- /web/frontend/assets/empty-state.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/environment.ts: -------------------------------------------------------------------------------- 1 | import { envsafe, str } from "envsafe"; 2 | 3 | export const environment = envsafe({ 4 | NODE_ENV: str({ default: "development" }), 5 | BACKEND_PORT: str({}), 6 | PORT: str({}), 7 | SHOPIFY_API_KEY: str({}), 8 | SHOPIFY_API_SECRET: str({}), 9 | SCOPES: str({}), 10 | HOST: str({}), 11 | }); 12 | -------------------------------------------------------------------------------- /web/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Ignore Apple macOS Desktop Services Store 5 | .DS_Store 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # vite build output 12 | dist/ 13 | 14 | # Partners can use npm, yarn or pnpm with the CLI. 15 | # We ignore lock files so they don't get a package manager mis-match 16 | # Without this, they may get a warning if using a different package manager to us 17 | yarn.lock 18 | package-lock.json 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-app-template", 3 | "version": "1.0.0", 4 | "main": "web/index.js", 5 | "license": "UNLICENSED", 6 | "scripts": { 7 | "shopify": "shopify", 8 | "build": "shopify app build", 9 | "dev": "shopify app dev", 10 | "info": "shopify app info", 11 | "generate": "shopify app generate", 12 | "deploy": "shopify app deploy" 13 | }, 14 | "dependencies": { 15 | "@shopify/app": "3.28.0", 16 | "@shopify/cli": "3.28.0" 17 | }, 18 | "author": "kai" 19 | } 20 | -------------------------------------------------------------------------------- /web/frontend/components/providers/QueryProvider.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | QueryClient, 3 | QueryClientProvider, 4 | QueryCache, 5 | MutationCache, 6 | } from "react-query"; 7 | 8 | /** 9 | * Sets up the QueryClientProvider from react-query. 10 | * @desc See: https://react-query.tanstack.com/reference/QueryClientProvider#_top 11 | */ 12 | export function QueryProvider({ children }) { 13 | const client = new QueryClient({ 14 | queryCache: new QueryCache(), 15 | mutationCache: new MutationCache(), 16 | }); 17 | 18 | return {children}; 19 | } 20 | -------------------------------------------------------------------------------- /web/helpers/return-top-level-redirection.js: -------------------------------------------------------------------------------- 1 | export default function returnTopLevelRedirection(req, res, redirectUrl) { 2 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/); 3 | 4 | // If the request has a bearer token, the app is currently embedded, and must break out of the iframe to 5 | // re-authenticate 6 | if (bearerPresent) { 7 | res.status(403); 8 | res.header("X-Shopify-API-Request-Failure-Reauthorize", "1"); 9 | res.header("X-Shopify-API-Request-Failure-Reauthorize-Url", redirectUrl); 10 | res.end(); 11 | } else { 12 | res.redirect(redirectUrl); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es2021: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | ], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: "latest", 19 | sourceType: "module", 20 | }, 21 | plugins: ["react", "@typescript-eslint", "plugin:prettier/recommended"], 22 | rules: { 23 | "react/react-in-jsx-scope": 0, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /web/frontend/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Card, EmptyState, Page } from "@shopify/polaris"; 2 | import { notFoundImage } from "../assets"; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | 8 | 9 | 13 |

14 | Check the URL and try again, or use the search bar to find what 15 | you need. 16 |

17 |
18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | .env 3 | .env.* 4 | 5 | # Dependency directory 6 | node_modules 7 | 8 | # Test coverage directory 9 | coverage 10 | 11 | # Ignore Apple macOS Desktop Services Store 12 | .DS_Store 13 | 14 | # Logs 15 | logs 16 | *.log 17 | 18 | # ngrok tunnel file 19 | config/tunnel.pid 20 | 21 | # vite build output 22 | dist/ 23 | 24 | # extensions build output 25 | extensions/*/build 26 | 27 | # Node library SQLite database 28 | web/database.sqlite 29 | 30 | # Partners can use npm, yarn or pnpm with the CLI. 31 | # We ignore lock files so they don't get a package manager mis-match 32 | # Without this, they may get a warning if using a different package manager to us 33 | yarn.lock 34 | package-lock.json 35 | pnpm-lock.yaml 36 | -------------------------------------------------------------------------------- /web/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Shopify React Frontend App 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) 4 | 5 | This repository is the frontend for Shopify’s app starter templates. **You probably don’t want to use this repository directly**, but rather through one of the templates and the [Shopify CLI](https://github.com/Shopify/shopify-cli). 6 | 7 | ## Developer resources 8 | 9 | - [Introduction to Shopify apps](https://shopify.dev/apps/getting-started) 10 | - [App authentication](https://shopify.dev/apps/auth) 11 | - [Shopify CLI command reference](https://shopify.dev/apps/tools/cli/app) 12 | - [Shopify API Library documentation](https://github.com/Shopify/shopify-node-api/tree/main/docs) 13 | 14 | ## License 15 | 16 | This repository is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 17 | -------------------------------------------------------------------------------- /web/app_installations.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from "@shopify/shopify-api"; 2 | 3 | export const AppInstallations = { 4 | includes: async function (shopDomain: string) { 5 | const shopSessions = 6 | (await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.( 7 | shopDomain, 8 | )) || []; 9 | 10 | if (shopSessions.length > 0) { 11 | for (const session of shopSessions) { 12 | if (session.accessToken) return true; 13 | } 14 | } 15 | 16 | return false; 17 | }, 18 | 19 | delete: async function (shopDomain: string) { 20 | const shopSessions = 21 | (await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.( 22 | shopDomain, 23 | )) || []; 24 | if (shopSessions.length > 0) { 25 | await Shopify.Context.SESSION_STORAGE.deleteSessions?.( 26 | shopSessions.map((session) => session.id), 27 | ); 28 | } 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /web/frontend/pages/ExitIframe.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from "@shopify/app-bridge/actions"; 2 | import { useAppBridge, Loading } from "@shopify/app-bridge-react"; 3 | import { useEffect } from "react"; 4 | import { useLocation } from "react-router-dom"; 5 | 6 | export default function ExitIframe() { 7 | const app = useAppBridge(); 8 | const { search } = useLocation(); 9 | 10 | useEffect(() => { 11 | if (!!app && !!search) { 12 | const params = new URLSearchParams(search); 13 | const redirectUri = params.get("redirectUri"); 14 | const url = new URL(decodeURIComponent(redirectUri)); 15 | 16 | if (url.hostname === location.hostname) { 17 | const redirect = Redirect.create(app); 18 | redirect.dispatch( 19 | Redirect.Action.REMOTE, 20 | decodeURIComponent(redirectUri), 21 | ); 22 | } 23 | } 24 | }, [app, search]); 25 | 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "KaiSpencer", 11 | "name": "Kai Spencer", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/51139521?v=4", 13 | "profile": "https://github.com/KaiSpencer", 14 | "contributions": [ 15 | "code" 16 | ] 17 | }, 18 | { 19 | "login": "cyrilchapon", 20 | "name": "Cyril CHAPON", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/10728426?v=4", 22 | "profile": "https://github.com/cyrilchapon", 23 | "contributions": [ 24 | "code" 25 | ] 26 | } 27 | ], 28 | "contributorsPerLine": 7, 29 | "skipCi": true, 30 | "repoType": "github", 31 | "repoHost": "https://github.com", 32 | "projectName": "shopify-app-template-node-ts", 33 | "projectOwner": "KaiSpencer" 34 | } 35 | -------------------------------------------------------------------------------- /web/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "allowJs": true, 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "esModuleInterop": true, 11 | "target": "esnext", 12 | "noImplicitAny": true, 13 | "moduleResolution": "node", 14 | "sourceMap": true, 15 | "skipLibCheck": true, 16 | "allowSyntheticDefaultImports": true, 17 | "outDir": "dist", 18 | "baseUrl": ".", 19 | "noEmit": true, 20 | "paths": { 21 | "*": [ 22 | "node_modules/*" 23 | ] 24 | }, 25 | "resolveJsonModule": true, 26 | "isolatedModules": true, 27 | "jsx": "react-jsx","types": ["vite/client"] 28 | }, 29 | "include": [ 30 | "**/*.ts", 31 | "**/*.tsx", 32 | "**/*.js", 33 | "**/*.jsx", 34 | ], 35 | "exclude": [ 36 | "node_modules/**" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /web/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /web/frontend/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from "react-router-dom"; 2 | import { NavigationMenu } from "@shopify/app-bridge-react"; 3 | import Routes from "./Routes"; 4 | 5 | import { 6 | AppBridgeProvider, 7 | QueryProvider, 8 | PolarisProvider, 9 | } from "./components"; 10 | 11 | export default function App() { 12 | // Any .tsx or .jsx files in /pages will become a route 13 | // See documentation for for more info 14 | const pages = import.meta.globEager("./pages/**/!(*.test.[jt]sx)*.([jt]sx)"); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-app-template-node", 3 | "private": true, 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "debug": "node --inspect-brk index.js", 7 | "dev": "cross-env NODE_ENV=development nodemon --exec node --loader ts-node/esm index.ts --ignore ./frontend", 8 | "serve": "cross-env NODE_ENV=production node index.js" 9 | }, 10 | "type": "module", 11 | "engines": { 12 | "node": ">=14.13.1" 13 | }, 14 | "dependencies": { 15 | "@shopify/shopify-api": "^5.0.0", 16 | "compression": "^1.7.4", 17 | "cookie-parser": "^1.4.6", 18 | "cross-env": "^7.0.3", 19 | "envsafe": "^2.0.3", 20 | "express": "^4.17.3", 21 | "serve-static": "^1.14.1" 22 | }, 23 | "devDependencies": { 24 | "@types/compression": "^1.7.2", 25 | "@types/cookie-parser": "^1.4.3", 26 | "@types/express": "^4.17.14", 27 | "@types/node": "^18.8.2", 28 | "@typescript-eslint/parser": "^5.39.0", 29 | "jsonwebtoken": "^8.5.1", 30 | "nodemon": "^2.0.15", 31 | "prettier": "^2.6.2", 32 | "pretty-quick": "^3.1.3", 33 | "ts-node": "^10.9.1", 34 | "ts-node-dev": "^2.0.0", 35 | "typescript": "^4.8.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/helpers/redirect-to-auth.js: -------------------------------------------------------------------------------- 1 | import { Shopify } from "@shopify/shopify-api"; 2 | 3 | export default async function redirectToAuth(req, res, app) { 4 | if (!req.query.shop) { 5 | res.status(500); 6 | return res.send("No shop provided"); 7 | } 8 | 9 | if (req.query.embedded === "1") { 10 | return clientSideRedirect(req, res); 11 | } 12 | 13 | return await serverSideRedirect(req, res, app); 14 | } 15 | 16 | function clientSideRedirect(req, res) { 17 | const shop = Shopify.Utils.sanitizeShop(req.query.shop); 18 | const redirectUriParams = new URLSearchParams({ 19 | shop, 20 | host: req.query.host, 21 | }).toString(); 22 | const queryParams = new URLSearchParams({ 23 | ...req.query, 24 | shop, 25 | redirectUri: `https://${Shopify.Context.HOST_NAME}/api/auth?${redirectUriParams}`, 26 | }).toString(); 27 | 28 | return res.redirect(`/exitiframe?${queryParams}`); 29 | } 30 | 31 | async function serverSideRedirect(req, res, app) { 32 | const redirectUrl = await Shopify.Auth.beginAuth( 33 | req, 34 | res, 35 | req.query.shop, 36 | "/api/auth/callback", 37 | app.get("use-online-tokens") 38 | ); 39 | 40 | return res.redirect(redirectUrl); 41 | } 42 | -------------------------------------------------------------------------------- /web/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-frontend-template-react", 3 | "private": true, 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "build": "vite build", 7 | "dev": "vite", 8 | "test": "vitest", 9 | "coverage": "vitest run --coverage", 10 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 11 | "lint:fix": "pnpm run lint -- --fix" 12 | }, 13 | "type": "module", 14 | "engines": { 15 | "node": ">= 12.16" 16 | }, 17 | "dependencies": { 18 | "@shopify/app-bridge": "^3.1.0", 19 | "@shopify/app-bridge-react": "^3.1.0", 20 | "@shopify/app-bridge-utils": "^3.1.0", 21 | "@shopify/polaris": "^9.11.0", 22 | "@vitejs/plugin-react": "1.2.0", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "react-query": "^3.34.19", 26 | "react-router-dom": "^6.3.0", 27 | "vite": "^2.8.6" 28 | }, 29 | "devDependencies": { 30 | "@typescript-eslint/eslint-plugin": "^5.39.0", 31 | "@typescript-eslint/parser": "^5.39.0", 32 | "eslint": "^8.24.0", 33 | "eslint-config-prettier": "^8.5.0", 34 | "eslint-plugin-prettier": "^4.2.1", 35 | "eslint-plugin-react": "^7.31.8", 36 | "history": "^5.3.0", 37 | "jsdom": "^19.0.0", 38 | "prettier": "^2.7.1", 39 | "typescript": "^4.8.4", 40 | "vi-fetch": "^0.6.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/frontend/pages/pagename.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Page, Layout, TextContainer, Heading } from "@shopify/polaris"; 2 | import { TitleBar } from "@shopify/app-bridge-react"; 3 | 4 | export default function PageName() { 5 | return ( 6 | 7 | console.log("Primary action"), 12 | }} 13 | secondaryActions={[ 14 | { 15 | content: "Secondary action", 16 | onAction: () => console.log("Secondary action"), 17 | }, 18 | ]} 19 | /> 20 | 21 | 22 | 23 | Heading 24 | 25 |

Body

26 |
27 |
28 | 29 | Heading 30 | 31 |

Body

32 |
33 |
34 |
35 | 36 | 37 | Heading 38 | 39 |

Body

40 |
41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /web/frontend/hooks/useAppQuery.ts: -------------------------------------------------------------------------------- 1 | import { useAuthenticatedFetch } from "./useAuthenticatedFetch"; 2 | import { useMemo } from "react"; 3 | import { useQuery, UseQueryOptions } from "react-query"; 4 | 5 | /** 6 | * A hook for querying your custom app data. 7 | * @desc A thin wrapper around useAuthenticatedFetch and react-query's useQuery. 8 | * 9 | * @param {Object} options - The options for your query. Accepts 3 keys: 10 | * 11 | * 1. url: The URL to query. E.g: /api/widgets/1` 12 | * 2. fetchInit: The init options for fetch. See: https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters 13 | * 3. reactQueryOptions: The options for `useQuery`. See: https://react-query.tanstack.com/reference/useQuery 14 | * 15 | * @returns Return value of useQuery. See: https://react-query.tanstack.com/reference/useQuery. 16 | */ 17 | export const useAppQuery = ({ 18 | url, 19 | fetchInit = {}, 20 | reactQueryOptions, 21 | }: { 22 | url: string; 23 | fetchInit?: RequestInit; 24 | reactQueryOptions: Omit< 25 | UseQueryOptions, 26 | "queryKey" | "queryFn" 27 | >; 28 | }) => { 29 | const authenticatedFetch = useAuthenticatedFetch(); 30 | const fetch = useMemo(() => { 31 | return async () => { 32 | const response = await authenticatedFetch(url, fetchInit); 33 | return response.json(); 34 | }; 35 | }, [url, JSON.stringify(fetchInit)]); 36 | 37 | return useQuery(url, fetch, { 38 | ...reactQueryOptions, 39 | refetchOnWindowFocus: false, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /web/frontend/components/providers/PolarisProvider.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { AppProvider } from "@shopify/polaris"; 3 | import { useNavigate } from "@shopify/app-bridge-react"; 4 | import translations from "@shopify/polaris/locales/en.json"; 5 | import "@shopify/polaris/build/esm/styles.css"; 6 | 7 | function AppBridgeLink({ url, children, external, ...rest }) { 8 | const navigate = useNavigate(); 9 | const handleClick = useCallback(() => { 10 | navigate(url); 11 | }, [url]); 12 | 13 | const IS_EXTERNAL_LINK_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/; 14 | 15 | if (external || IS_EXTERNAL_LINK_REGEX.test(url)) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | 30 | /** 31 | * Sets up the AppProvider from Polaris. 32 | * @desc PolarisProvider passes a custom link component to Polaris. 33 | * The Link component handles navigation within an embedded app. 34 | * Prefer using this vs any other method such as an anchor. 35 | * Use it by importing Link from Polaris, e.g: 36 | * 37 | * ``` 38 | * import {Link} from '@shopify/polaris' 39 | * 40 | * function MyComponent() { 41 | * return ( 42 | *
Tab 2
43 | * ) 44 | * } 45 | * ``` 46 | * 47 | * PolarisProvider also passes translations to Polaris. 48 | * 49 | */ 50 | export function PolarisProvider({ children }) { 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /web/frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { dirname } from "path"; 3 | import { fileURLToPath } from "url"; 4 | import https from "https"; 5 | import react from "@vitejs/plugin-react"; 6 | 7 | if ( 8 | process.env.npm_lifecycle_event === "build" && 9 | !process.env.CI && 10 | !process.env.SHOPIFY_API_KEY 11 | ) { 12 | console.warn( 13 | "\nBuilding the frontend app without an API key. The frontend build will not run without an API key. Set the SHOPIFY_API_KEY environment variable when running the build command.\n" 14 | ); 15 | } 16 | 17 | const proxyOptions = { 18 | target: `http://127.0.0.1:${process.env.BACKEND_PORT}`, 19 | changeOrigin: false, 20 | secure: true, 21 | ws: false, 22 | }; 23 | 24 | const host = process.env.HOST 25 | ? process.env.HOST.replace(/https?:\/\//, "") 26 | : "localhost"; 27 | 28 | let hmrConfig; 29 | if (host === "localhost") { 30 | hmrConfig = { 31 | protocol: "ws", 32 | host: "localhost", 33 | port: 64999, 34 | clientPort: 64999, 35 | }; 36 | } else { 37 | hmrConfig = { 38 | protocol: "wss", 39 | host: host, 40 | port: process.env.FRONTEND_PORT, 41 | clientPort: 443, 42 | }; 43 | } 44 | 45 | export default defineConfig({ 46 | root: dirname(fileURLToPath(import.meta.url)), 47 | plugins: [react()], 48 | define: { 49 | "process.env.SHOPIFY_API_KEY": JSON.stringify(process.env.SHOPIFY_API_KEY), 50 | }, 51 | resolve: { 52 | preserveSymlinks: true, 53 | }, 54 | server: { 55 | host: "localhost", 56 | port: process.env.FRONTEND_PORT, 57 | hmr: hmrConfig, 58 | proxy: { 59 | "^/(\\?.*)?$": proxyOptions, 60 | "^/api(/|(\\?.*)?$)": proxyOptions, 61 | }, 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /web/frontend/hooks/useAuthenticatedFetch.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedFetch } from "@shopify/app-bridge-utils"; 2 | import { useAppBridge } from "@shopify/app-bridge-react"; 3 | import { Redirect } from "@shopify/app-bridge/actions"; 4 | import { ClientApplication, AppBridgeState } from "@shopify/app-bridge"; 5 | 6 | /** 7 | * A hook that returns an auth-aware fetch function. 8 | * @desc The returned fetch function that matches the browser's fetch API 9 | * See: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 10 | * It will provide the following functionality: 11 | * 12 | * 1. Add a `X-Shopify-Access-Token` header to the request. 13 | * 2. Check response for `X-Shopify-API-Request-Failure-Reauthorize` header. 14 | * 3. Redirect the user to the reauthorization URL if the header is present. 15 | * 16 | * @returns {Function} fetch function 17 | */ 18 | export function useAuthenticatedFetch() { 19 | const app = useAppBridge(); 20 | const fetchFunction = authenticatedFetch(app); 21 | 22 | return async (uri: RequestInfo, options?: RequestInit) => { 23 | const response = await fetchFunction(uri, options); 24 | checkHeadersForReauthorization(response.headers, app); 25 | return response; 26 | }; 27 | } 28 | 29 | function checkHeadersForReauthorization( 30 | headers: Headers, 31 | app: ClientApplication, 32 | ) { 33 | if (headers.get("X-Shopify-API-Request-Failure-Reauthorize") === "1") { 34 | const authUrlHeader = 35 | headers.get("X-Shopify-API-Request-Failure-Reauthorize-Url") || 36 | `/api/auth`; 37 | 38 | const redirect = Redirect.create(app); 39 | redirect.dispatch( 40 | Redirect.Action.REMOTE, 41 | authUrlHeader.startsWith("/") 42 | ? `https://${window.location.host}${authUrlHeader}` 43 | : authUrlHeader, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/frontend/components/ProductsCard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Card, 4 | Heading, 5 | TextContainer, 6 | DisplayText, 7 | TextStyle, 8 | } from "@shopify/polaris"; 9 | import { Toast } from "@shopify/app-bridge-react"; 10 | import { useAppQuery, useAuthenticatedFetch } from "../hooks"; 11 | 12 | export function ProductsCard() { 13 | const emptyToastProps: { content: string | null; error?: boolean } = { 14 | content: null, 15 | }; 16 | const [isLoading, setIsLoading] = useState(true); 17 | const [toastProps, setToastProps] = useState(emptyToastProps); 18 | const fetch = useAuthenticatedFetch(); 19 | 20 | const { 21 | data, 22 | refetch: refetchProductCount, 23 | isLoading: isLoadingCount, 24 | isRefetching: isRefetchingCount, 25 | } = useAppQuery({ 26 | url: "/api/products/count", 27 | reactQueryOptions: { 28 | onSuccess: () => { 29 | setIsLoading(false); 30 | }, 31 | }, 32 | }); 33 | 34 | const toastMarkup = toastProps.content && !isRefetchingCount && ( 35 | setToastProps(emptyToastProps)} /> 36 | ); 37 | 38 | const handlePopulate = async () => { 39 | setIsLoading(true); 40 | const response = await fetch("/api/products/create"); 41 | 42 | if (response.ok) { 43 | await refetchProductCount(); 44 | setToastProps({ content: "5 products created!" }); 45 | } else { 46 | setIsLoading(false); 47 | setToastProps({ 48 | content: "There was an error creating products", 49 | error: true, 50 | }); 51 | } 52 | }; 53 | 54 | return ( 55 | <> 56 | {toastMarkup} 57 | 66 | 67 |

68 | Sample products are created with a default title and price. You can 69 | remove them at any time. 70 |

71 | 72 | TOTAL PRODUCTS 73 | 74 | 75 | {isLoadingCount ? "-" : data.count} 76 | 77 | 78 | 79 |
80 |
81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /web/frontend/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { Routes as ReactRouterRoutes, Route } from "react-router-dom"; 2 | 3 | /** 4 | * File-based routing. 5 | * @desc File-based routing that uses React Router under the hood. 6 | * To create a new route create a new .jsx file in `/pages` with a default export. 7 | * 8 | * Some examples: 9 | * * `/pages/index.jsx` matches `/` 10 | * * `/pages/blog/[id].jsx` matches `/blog/123` 11 | * * `/pages/[...catchAll].jsx` matches any URL not explicitly matched 12 | * 13 | * @param {object} pages value of import.meta.globEager(). See https://vitejs.dev/guide/features.html#glob-import 14 | * 15 | * @return {Routes} `` from React Router, with a `` for each file in `pages` 16 | */ 17 | export default function Routes({ pages }: { pages: Record }) { 18 | const routes = useRoutes(pages); 19 | const routeComponents = routes.map(({ path, component: Component }) => ( 20 | } /> 21 | )); 22 | 23 | const NotFound = routes.find(({ path }) => path === "/notFound").component; 24 | 25 | return ( 26 | 27 | {routeComponents} 28 | } /> 29 | 30 | ); 31 | } 32 | 33 | function useRoutes(pages: Record) { 34 | const routes = Object.keys(pages) 35 | .map((key) => { 36 | let path = key 37 | .replace("./pages", "") 38 | .replace(/\.(t|j)sx?$/, "") 39 | /** 40 | * Replace /index with / 41 | */ 42 | .replace(/\/index$/i, "/") 43 | /** 44 | * Only lowercase the first letter. This allows the developer to use camelCase 45 | * dynamic paths while ensuring their standard routes are normalized to lowercase. 46 | */ 47 | .replace(/\b[A-Z]/, (firstLetter) => firstLetter.toLowerCase()) 48 | /** 49 | * Convert /[handle].jsx and /[...handle].jsx to /:handle.jsx for react-router-dom 50 | */ 51 | .replace(/\[(?:[.]{3})?(\w+?)\]/g, (_match, param) => `:${param}`); 52 | 53 | if (path.endsWith("/") && path !== "/") { 54 | path = path.substring(0, path.length - 1); 55 | } 56 | 57 | if (!pages[key].default) { 58 | console.warn(`${key} doesn't export a default React component`); 59 | } 60 | 61 | return { 62 | path, 63 | component: pages[key].default, 64 | }; 65 | }) 66 | .filter((route) => route.component); 67 | 68 | return routes; 69 | } 70 | -------------------------------------------------------------------------------- /web/gdpr.ts: -------------------------------------------------------------------------------- 1 | import { Shopify, WebhookRegistryEntry } from "@shopify/shopify-api"; 2 | 3 | export function setupGDPRWebHooks(path: WebhookRegistryEntry["path"]) { 4 | /** 5 | * Customers can request their data from a store owner. When this happens, 6 | * Shopify invokes this webhook. 7 | * 8 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-data_request 9 | */ 10 | Shopify.Webhooks.Registry.addHandler("CUSTOMERS_DATA_REQUEST", { 11 | path, 12 | webhookHandler: async (topic, shop, body) => { 13 | const payload = JSON.parse(body); 14 | // Payload has the following shape: 15 | // { 16 | // "shop_id": 954889, 17 | // "shop_domain": "{shop}.myshopify.com", 18 | // "orders_requested": [ 19 | // 299938, 20 | // 280263, 21 | // 220458 22 | // ], 23 | // "customer": { 24 | // "id": 191167, 25 | // "email": "john@example.com", 26 | // "phone": "555-625-1199" 27 | // }, 28 | // "data_request": { 29 | // "id": 9999 30 | // } 31 | // } 32 | }, 33 | }); 34 | 35 | /** 36 | * Store owners can request that data is deleted on behalf of a customer. When 37 | * this happens, Shopify invokes this webhook. 38 | * 39 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-redact 40 | */ 41 | Shopify.Webhooks.Registry.addHandler("CUSTOMERS_REDACT", { 42 | path, 43 | webhookHandler: async (topic, shop, body) => { 44 | const payload = JSON.parse(body); 45 | // Payload has the following shape: 46 | // { 47 | // "shop_id": 954889, 48 | // "shop_domain": "{shop}.myshopify.com", 49 | // "customer": { 50 | // "id": 191167, 51 | // "email": "john@example.com", 52 | // "phone": "555-625-1199" 53 | // }, 54 | // "orders_to_redact": [ 55 | // 299938, 56 | // 280263, 57 | // 220458 58 | // ] 59 | // } 60 | }, 61 | }); 62 | 63 | /** 64 | * 48 hours after a store owner uninstalls your app, Shopify invokes this 65 | * webhook. 66 | * 67 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#shop-redact 68 | */ 69 | Shopify.Webhooks.Registry.addHandler("SHOP_REDACT", { 70 | path, 71 | webhookHandler: async (topic, shop, body) => { 72 | const payload = JSON.parse(body); 73 | // Payload has the following shape: 74 | // { 75 | // "shop_id": 954889, 76 | // "shop_domain": "{shop}.myshopify.com" 77 | // } 78 | }, 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /web/helpers/product-creator.js: -------------------------------------------------------------------------------- 1 | import { Shopify } from "@shopify/shopify-api"; 2 | 3 | const ADJECTIVES = [ 4 | "autumn", 5 | "hidden", 6 | "bitter", 7 | "misty", 8 | "silent", 9 | "empty", 10 | "dry", 11 | "dark", 12 | "summer", 13 | "icy", 14 | "delicate", 15 | "quiet", 16 | "white", 17 | "cool", 18 | "spring", 19 | "winter", 20 | "patient", 21 | "twilight", 22 | "dawn", 23 | "crimson", 24 | "wispy", 25 | "weathered", 26 | "blue", 27 | "billowing", 28 | "broken", 29 | "cold", 30 | "damp", 31 | "falling", 32 | "frosty", 33 | "green", 34 | "long", 35 | ] 36 | 37 | const NOUNS = [ 38 | "waterfall", 39 | "river", 40 | "breeze", 41 | "moon", 42 | "rain", 43 | "wind", 44 | "sea", 45 | "morning", 46 | "snow", 47 | "lake", 48 | "sunset", 49 | "pine", 50 | "shadow", 51 | "leaf", 52 | "dawn", 53 | "glitter", 54 | "forest", 55 | "hill", 56 | "cloud", 57 | "meadow", 58 | "sun", 59 | "glade", 60 | "bird", 61 | "brook", 62 | "butterfly", 63 | "bush", 64 | "dew", 65 | "dust", 66 | "field", 67 | "fire", 68 | "flower", 69 | ] 70 | 71 | export const DEFAULT_PRODUCTS_COUNT = 5; 72 | const CREATE_PRODUCTS_MUTATION = ` 73 | mutation populateProduct($input: ProductInput!) { 74 | productCreate(input: $input) { 75 | product { 76 | id 77 | } 78 | } 79 | } 80 | ` 81 | 82 | export default async function productCreator(session, count = DEFAULT_PRODUCTS_COUNT) { 83 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 84 | 85 | try { 86 | for (let i = 0; i < count; i++) { 87 | await client.query({ 88 | data: { 89 | query: CREATE_PRODUCTS_MUTATION, 90 | variables: { 91 | input: { 92 | title: `${randomTitle()}`, 93 | variants: [{ price: randomPrice() }], 94 | }, 95 | }, 96 | }, 97 | }); 98 | } 99 | } catch (error) { 100 | if (error instanceof ShopifyErrors.GraphqlQueryError) { 101 | throw new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`); 102 | } else { 103 | throw error; 104 | } 105 | } 106 | } 107 | 108 | function randomTitle() { 109 | const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; 110 | const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; 111 | return `${adjective} ${noun}`; 112 | } 113 | 114 | function randomPrice() { 115 | return Math.round((Math.random() * 10 + Number.EPSILON) * 100) / 100; 116 | } 117 | -------------------------------------------------------------------------------- /web/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Page, 4 | Layout, 5 | TextContainer, 6 | Image, 7 | Stack, 8 | Link, 9 | Heading, 10 | } from "@shopify/polaris"; 11 | import { TitleBar } from "@shopify/app-bridge-react"; 12 | 13 | import { trophyImage } from "../assets"; 14 | 15 | import { ProductsCard } from "../components"; 16 | 17 | export default function HomePage() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | Nice work on building a Shopify app 🎉 33 |

34 | Your app is ready to explore! It contains everything you 35 | need to get started including the{" "} 36 | 37 | Polaris design system 38 | 39 | ,{" "} 40 | 41 | Shopify Admin API 42 | 43 | , and{" "} 44 | 48 | App Bridge 49 | {" "} 50 | UI library and components. 51 |

52 |

53 | Ready to go? Start populating your app with some sample 54 | products to view and test in your store.{" "} 55 |

56 |

57 | Learn more about building out your app in{" "} 58 | 62 | this Shopify tutorial 63 | {" "} 64 | 📚{" "} 65 |

66 |
67 |
68 | 69 |
70 | Nice work on building a Shopify app 75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 | 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /web/middleware/verify-request.ts: -------------------------------------------------------------------------------- 1 | import { Shopify } from "@shopify/shopify-api"; 2 | import { Application } from "express"; 3 | import ensureBilling, { 4 | ShopifyBillingError, 5 | } from "../helpers/ensure-billing.js"; 6 | import redirectToAuth from "../helpers/redirect-to-auth.js"; 7 | 8 | import returnTopLevelRedirection from "../helpers/return-top-level-redirection.js"; 9 | import { BillingSettingsType } from "../index.js"; 10 | 11 | const TEST_GRAPHQL_QUERY = ` 12 | { 13 | shop { 14 | name 15 | } 16 | }`; 17 | 18 | export default function verifyRequest( 19 | app: Application, 20 | { billing = { required: false } }: { billing: BillingSettingsType }, 21 | ) { 22 | return async (req: any, res: any, next: () => any) => { 23 | const session = await Shopify.Utils.loadCurrentSession( 24 | req, 25 | res, 26 | app.get("use-online-tokens"), 27 | ); 28 | 29 | let shop = Shopify.Utils.sanitizeShop(req.query.shop); 30 | if (session && shop && session.shop !== shop) { 31 | // The current request is for a different shop. Redirect gracefully. 32 | return redirectToAuth(req, res, app); 33 | } 34 | 35 | if (session?.isActive()) { 36 | try { 37 | if (billing.required) { 38 | // The request to check billing status serves to validate that the access token is still valid. 39 | const [hasPayment, confirmationUrl] = await ensureBilling( 40 | session, 41 | billing, 42 | ); 43 | 44 | if (!hasPayment) { 45 | returnTopLevelRedirection(req, res, confirmationUrl); 46 | return; 47 | } 48 | } else { 49 | // Make a request to ensure the access token is still valid. Otherwise, re-authenticate the user. 50 | const client = new Shopify.Clients.Graphql( 51 | session.shop, 52 | session.accessToken, 53 | ); 54 | await client.query({ data: TEST_GRAPHQL_QUERY }); 55 | } 56 | return next(); 57 | } catch (e) { 58 | if ( 59 | e instanceof Shopify.Errors.HttpResponseError && 60 | e.response.code === 401 61 | ) { 62 | // Re-authenticate if we get a 401 response 63 | } else if (e instanceof ShopifyBillingError) { 64 | console.error(e.message, (e as ShopifyBillingError).errorData[0]); 65 | res.status(500).end(); 66 | return; 67 | } else { 68 | throw e; 69 | } 70 | } 71 | } 72 | 73 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/); 74 | if (bearerPresent) { 75 | if (!shop) { 76 | if (session) { 77 | shop = session.shop; 78 | } else if (Shopify.Context.IS_EMBEDDED_APP) { 79 | if (bearerPresent) { 80 | const payload = Shopify.Utils.decodeSessionToken(bearerPresent[1]); 81 | shop = payload.dest.replace("https://", ""); 82 | } 83 | } 84 | } 85 | } 86 | if (!shop) throw new Error("No shop query parameter provided"); 87 | returnTopLevelRedirection( 88 | req, 89 | res, 90 | `/api/auth?shop=${encodeURIComponent(shop)}`, 91 | ); 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /web/frontend/components/providers/AppBridgeProvider.jsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import { Provider } from "@shopify/app-bridge-react"; 4 | import { Banner, Layout, Page } from "@shopify/polaris"; 5 | 6 | /** 7 | * A component to configure App Bridge. 8 | * @desc A thin wrapper around AppBridgeProvider that provides the following capabilities: 9 | * 10 | * 1. Ensures that navigating inside the app updates the host URL. 11 | * 2. Configures the App Bridge Provider, which unlocks functionality provided by the host. 12 | * 13 | * See: https://shopify.dev/apps/tools/app-bridge/react-components 14 | */ 15 | export function AppBridgeProvider({ children }) { 16 | const location = useLocation(); 17 | const navigate = useNavigate(); 18 | const history = useMemo( 19 | () => ({ 20 | replace: (path) => { 21 | navigate(path, { replace: true }); 22 | }, 23 | }), 24 | [navigate] 25 | ); 26 | 27 | const routerConfig = useMemo( 28 | () => ({ history, location }), 29 | [history, location] 30 | ); 31 | 32 | // The host may be present initially, but later removed by navigation. 33 | // By caching this in state, we ensure that the host is never lost. 34 | // During the lifecycle of an app, these values should never be updated anyway. 35 | // Using state in this way is preferable to useMemo. 36 | // See: https://stackoverflow.com/questions/60482318/version-of-usememo-for-caching-a-value-that-will-never-change 37 | const [appBridgeConfig] = useState(() => { 38 | const host = 39 | new URLSearchParams(location.search).get("host") || 40 | window.__SHOPIFY_DEV_HOST; 41 | 42 | window.__SHOPIFY_DEV_HOST = host; 43 | 44 | return { 45 | host, 46 | apiKey: process.env.SHOPIFY_API_KEY, 47 | forceRedirect: true, 48 | }; 49 | }); 50 | 51 | if (!process.env.SHOPIFY_API_KEY || !appBridgeConfig.host) { 52 | const bannerProps = !process.env.SHOPIFY_API_KEY 53 | ? { 54 | title: "Missing Shopify API Key", 55 | children: ( 56 | <> 57 | Your app is running without the SHOPIFY_API_KEY environment 58 | variable. Please ensure that it is set when running or building 59 | your React app. 60 | 61 | ), 62 | } 63 | : { 64 | title: "Missing host query argument", 65 | children: ( 66 | <> 67 | Your app can only load if the URL has a host argument. 68 | Please ensure that it is set, or access your app using the 69 | Partners Dashboard Test your app feature 70 | 71 | ), 72 | }; 73 | 74 | return ( 75 | 76 | 77 | 78 |
79 | 80 |
81 |
82 |
83 |
84 | ); 85 | } 86 | 87 | return ( 88 | 89 | {children} 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /web/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthQuery, BillingSettings, Shopify } from "@shopify/shopify-api"; 2 | import { gdprTopics } from "@shopify/shopify-api/dist/webhooks/registry.js"; 3 | import { Application } from "express"; 4 | import ensureBilling from "../helpers/ensure-billing.js"; 5 | import redirectToAuth from "../helpers/redirect-to-auth.js"; 6 | import { BillingSettingsType } from "../index.js"; 7 | 8 | export default function applyAuthMiddleware( 9 | app: Application, 10 | { 11 | billing, 12 | }: { 13 | billing: BillingSettingsType; 14 | }, 15 | ) { 16 | app.get("/api/auth", async (req, res) => { 17 | return redirectToAuth(req, res, app); 18 | }); 19 | 20 | app.get( 21 | "/api/auth/callback", 22 | async (req, res) => { 23 | try { 24 | const session = await Shopify.Auth.validateAuthCallback( 25 | req, 26 | res, 27 | req.query, 28 | ); 29 | if (!session.accessToken) { 30 | throw new Error("No access token found in session"); 31 | } 32 | 33 | const responses = await Shopify.Webhooks.Registry.registerAll({ 34 | shop: session.shop, 35 | accessToken: session.accessToken, 36 | }); 37 | 38 | Object.entries(responses).map(([topic, response]) => { 39 | const res = response as { 40 | success: boolean; 41 | result: { errors?: any[]; data?: any }; 42 | }; 43 | // The response from registerAll will include errors for the GDPR topics. These can be safely ignored. 44 | // To register the GDPR topics, please set the appropriate webhook endpoint in the 45 | // 'GDPR mandatory webhooks' section of 'App setup' in the Partners Dashboard. 46 | if (!res.success && !gdprTopics.includes(topic)) { 47 | if (res.result.errors) { 48 | console.log( 49 | `Failed to register ${topic} webhook: ${res.result.errors[0].message}`, 50 | ); 51 | } else { 52 | console.log( 53 | `Failed to register ${topic} webhook: ${JSON.stringify( 54 | res.result.data, 55 | undefined, 56 | 2, 57 | )}`, 58 | ); 59 | } 60 | } 61 | }); 62 | 63 | // If billing is required, check if the store needs to be charged right away to minimize the number of redirects. 64 | if (billing.required) { 65 | const [hasPayment, confirmationUrl] = await ensureBilling( 66 | session, 67 | billing, 68 | ); 69 | 70 | if (!hasPayment) { 71 | return res.redirect(confirmationUrl); 72 | } 73 | } 74 | const reqHost = req.query.host; 75 | if (!reqHost) throw new Error("No host found in query"); 76 | const host = Shopify.Utils.sanitizeHost(reqHost); 77 | if (!host) throw new Error("No host sanitized from query"); 78 | const redirectUrl = Shopify.Context.IS_EMBEDDED_APP 79 | ? Shopify.Utils.getEmbeddedAppUrl(req) 80 | : `/?shop=${session.shop}&host=${encodeURIComponent(host)}`; 81 | 82 | res.redirect(redirectUrl); 83 | } catch (e: any) { 84 | console.warn(e); 85 | switch (true) { 86 | case e instanceof Shopify.Errors.InvalidOAuthError: 87 | res.status(400); 88 | res.send(e.message); 89 | break; 90 | case e instanceof Shopify.Errors.CookieNotFound: 91 | case e instanceof Shopify.Errors.SessionNotFound: 92 | // This is likely because the OAuth session cookie expired before the merchant approved the request 93 | return redirectToAuth(req, res, app); 94 | break; 95 | default: 96 | res.status(500); 97 | res.send(e.message); 98 | break; 99 | } 100 | } 101 | }, 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported versions 4 | 5 | ### New features 6 | 7 | New features will only be added to the master branch and will not be made available in point releases. 8 | 9 | ### Bug fixes 10 | 11 | Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from. 12 | 13 | ### Security issues 14 | 15 | Only the latest release series will receive patches and new versions in case of a security issue. 16 | 17 | ### Severe security issues 18 | 19 | For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team. 20 | 21 | ### Unsupported Release Series 22 | 23 | When a release series is no longer supported, it's your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. 24 | 25 | ## Reporting a bug 26 | 27 | All security bugs in shopify repositories should be reported to [our hackerone program](https://hackerone.com/shopify) 28 | Shopify's whitehat program is our way to reward security researchers for finding serious security vulnerabilities in the In Scope properties listed at the bottom of this page, including our core application (all functionality associated with a Shopify store, particularly your-store.myshopify.com/admin) and certain ancillary applications. 29 | 30 | ## Disclosure Policy 31 | 32 | We look forward to working with all security researchers and strive to be respectful, always assume the best and treat others as peers. We expect the same in return from all participants. To achieve this, our team strives to: 33 | 34 | - Reply to all reports within one business day and triage within two business days (if applicable) 35 | - Be as transparent as possible, answering all inquires about our report decisions and adding hackers to duplicate HackerOne reports 36 | - Award bounties within a week of resolution (excluding extenuating circumstances) 37 | - Only close reports as N/A when the issue reported is included in Known Issues, Ineligible Vulnerabilities Types or lacks evidence of a vulnerability 38 | 39 | **The following rules must be followed in order for any rewards to be paid:** 40 | 41 | - You may only test against shops you have created which include your HackerOne YOURHANDLE @ wearehackerone.com registered email address. 42 | - You must not attempt to gain access to, or interact with, any shops other than those created by you. 43 | - The use of commercial scanners is prohibited (e.g., Nessus). 44 | - Rules for reporting must be followed. 45 | - Do not disclose any issues publicly before they have been resolved. 46 | - Shopify reserves the right to modify the rules for this program or deem any submissions invalid at any time. Shopify may cancel the whitehat program without notice at any time. 47 | - Contacting Shopify Support over chat, email or phone about your HackerOne report is not allowed. We may disqualify you from receiving a reward, or from participating in the program altogether. 48 | - You are not an employee of Shopify; employees should report bugs to the internal bug bounty program. 49 | - You hereby represent, warrant and covenant that any content you submit to Shopify is an original work of authorship and that you are legally entitled to grant the rights and privileges conveyed by these terms. You further represent, warrant and covenant that the consent of no other person or entity is or will be necessary for Shopify to use the submitted content. 50 | - By submitting content to Shopify, you irrevocably waive all moral rights which you may have in the content. 51 | - All content submitted by you to Shopify under this program is licensed under the MIT License. 52 | - You must report any discovered vulnerability to Shopify as soon as you have validated the vulnerability. 53 | - Failure to follow any of the foregoing rules will disqualify you from participating in this program. 54 | 55 | \*\* Please see our [Hackerone Profile](https://hackerone.com/shopify) for full details 56 | 57 | ## Receiving Security Updates 58 | 59 | To recieve all general updates to vulnerabilities, please subscribe to our hackerone [Hacktivity](https://hackerone.com/shopify/hacktivity) 60 | -------------------------------------------------------------------------------- /web/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { join } from "path"; 3 | import { readFileSync } from "fs"; 4 | import express from "express"; 5 | import cookieParser from "cookie-parser"; 6 | import { 7 | Shopify, 8 | LATEST_API_VERSION, 9 | BillingSettings, 10 | } from "@shopify/shopify-api"; 11 | 12 | import applyAuthMiddleware from "./middleware/auth.js"; 13 | import verifyRequest from "./middleware/verify-request.js"; 14 | import { setupGDPRWebHooks } from "./gdpr.js"; 15 | import productCreator from "./helpers/product-creator.js"; 16 | import redirectToAuth from "./helpers/redirect-to-auth.js"; 17 | import { AppInstallations } from "./app_installations.js"; 18 | import { environment } from "./environment.js"; 19 | 20 | const USE_ONLINE_TOKENS = false; 21 | 22 | const PORT = parseInt(environment.BACKEND_PORT || environment.PORT, 10); 23 | 24 | // TODO: There should be provided by env vars 25 | const DEV_INDEX_PATH = `${process.cwd()}/frontend/`; 26 | const PROD_INDEX_PATH = `${process.cwd()}/frontend/dist/`; 27 | 28 | const DB_PATH = `${process.cwd()}/database.sqlite`; 29 | 30 | Shopify.Context.initialize({ 31 | API_KEY: environment.SHOPIFY_API_KEY, 32 | API_SECRET_KEY: environment.SHOPIFY_API_SECRET, 33 | SCOPES: environment.SCOPES.split(","), 34 | HOST_NAME: environment.HOST.replace(/https?:\/\//, ""), 35 | HOST_SCHEME: environment.HOST.split("://")[0], 36 | API_VERSION: LATEST_API_VERSION, 37 | IS_EMBEDDED_APP: true, 38 | // This should be replaced with your preferred storage strategy 39 | SESSION_STORAGE: new Shopify.Session.SQLiteSessionStorage(DB_PATH), 40 | }); 41 | 42 | Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", { 43 | path: "/api/webhooks", 44 | webhookHandler: async (_topic, shop, _body) => { 45 | await AppInstallations.delete(shop); 46 | }, 47 | }); 48 | 49 | // The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production. 50 | // See the ensureBilling helper to learn more about billing in this template. 51 | export type BillingSettingsType = T extends true 52 | ? BillingSettings & { required: T } 53 | : { required: T }; 54 | 55 | const BILLING_SETTINGS: BillingSettingsType = { 56 | required: false, 57 | // This is an example configuration that would do a one-time charge for $5 (only USD is currently supported) 58 | // chargeName: "My Shopify One-Time Charge", 59 | // amount: 5.0, 60 | // currencyCode: "USD", 61 | // interval: BillingInterval.OneTime, 62 | }; 63 | 64 | // This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint 65 | // in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize 66 | // the code when you store customer data. 67 | // 68 | // More details can be found on shopify.dev: 69 | // https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks 70 | setupGDPRWebHooks("/api/webhooks"); 71 | 72 | // export for test use only 73 | export async function createServer( 74 | root = process.cwd(), 75 | isProd = environment.NODE_ENV === "production", 76 | billingSettings = BILLING_SETTINGS, 77 | ) { 78 | const app = express(); 79 | 80 | app.set("use-online-tokens", USE_ONLINE_TOKENS); 81 | app.use(cookieParser(Shopify.Context.API_SECRET_KEY)); 82 | 83 | applyAuthMiddleware(app, { 84 | billing: billingSettings, 85 | }); 86 | 87 | // Do not call app.use(express.json()) before processing webhooks with 88 | // Shopify.Webhooks.Registry.process(). 89 | // See https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers 90 | // for more details. 91 | app.post("/api/webhooks", async (req, res) => { 92 | try { 93 | await Shopify.Webhooks.Registry.process(req, res); 94 | console.log(`Webhook processed, returned status code 200`); 95 | } catch (e: any) { 96 | console.log(`Failed to process webhook: ${e.message}`); 97 | if (!res.headersSent) { 98 | res.status(500).send(e.message); 99 | } 100 | } 101 | }); 102 | 103 | // All endpoints after this point will require an active session 104 | app.use( 105 | "/api/*", 106 | verifyRequest(app, { 107 | billing: billingSettings, 108 | }), 109 | ); 110 | 111 | app.get("/api/products/count", async (req, res) => { 112 | const session = await Shopify.Utils.loadCurrentSession( 113 | req, 114 | res, 115 | app.get("use-online-tokens"), 116 | ); 117 | const { Product } = await import( 118 | `@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js` 119 | ); 120 | 121 | const countData = await Product.count({ session }); 122 | res.status(200).send(countData); 123 | }); 124 | 125 | app.get("/api/products/create", async (req, res) => { 126 | const session = await Shopify.Utils.loadCurrentSession( 127 | req, 128 | res, 129 | app.get("use-online-tokens"), 130 | ); 131 | let status = 200; 132 | let error = null; 133 | 134 | try { 135 | await productCreator(session); 136 | } catch (e: any) { 137 | console.log(`Failed to process products/create: ${e.message}`); 138 | status = 500; 139 | error = e.message; 140 | } 141 | res.status(status).send({ success: status === 200, error }); 142 | }); 143 | 144 | // All endpoints after this point will have access to a request.body 145 | // attribute, as a result of the express.json() middleware 146 | app.use(express.json()); 147 | 148 | app.use((req, res, next) => { 149 | const shopQuery = req.query.shop; 150 | if (!shopQuery) throw new Error("No shop query parameter provided"); 151 | if (typeof shopQuery !== "string") 152 | throw new Error("Invalid shop query parameter provided"); 153 | const shop = Shopify.Utils.sanitizeShop(shopQuery); 154 | if (Shopify.Context.IS_EMBEDDED_APP && shop) { 155 | res.setHeader( 156 | "Content-Security-Policy", 157 | `frame-ancestors https://${encodeURIComponent( 158 | shop, 159 | )} https://admin.shopify.com;`, 160 | ); 161 | } else { 162 | res.setHeader("Content-Security-Policy", `frame-ancestors 'none';`); 163 | } 164 | next(); 165 | }); 166 | 167 | if (isProd) { 168 | const compression = await import("compression").then( 169 | ({ default: fn }) => fn, 170 | ); 171 | const serveStatic = await import("serve-static").then( 172 | ({ default: fn }) => fn, 173 | ); 174 | app.use(compression()); 175 | app.use(serveStatic(PROD_INDEX_PATH, { index: false })); 176 | } 177 | 178 | app.use("/*", async (req, res, next) => { 179 | if (typeof req.query.shop !== "string") { 180 | res.status(500); 181 | return res.send("No shop provided"); 182 | } 183 | 184 | const shop = Shopify.Utils.sanitizeShop(req.query.shop); 185 | if (!shop) throw new Error("Invalid shop provided"); 186 | const appInstalled = await AppInstallations.includes(shop); 187 | 188 | if (!appInstalled && !req.originalUrl.match(/^\/exitiframe/i)) { 189 | return redirectToAuth(req, res, app); 190 | } 191 | 192 | if (Shopify.Context.IS_EMBEDDED_APP && req.query.embedded !== "1") { 193 | const embeddedUrl = Shopify.Utils.getEmbeddedAppUrl(req); 194 | 195 | return res.redirect(embeddedUrl + req.path); 196 | } 197 | 198 | const htmlFile = join( 199 | isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH, 200 | "index.html", 201 | ); 202 | 203 | return res 204 | .status(200) 205 | .set("Content-Type", "text/html") 206 | .send(readFileSync(htmlFile)); 207 | }); 208 | 209 | return { app }; 210 | } 211 | 212 | createServer().then(({ app }) => app.listen(PORT)); 213 | -------------------------------------------------------------------------------- /web/helpers/ensure-billing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BillingInterval, 3 | BillingSettings, 4 | SessionInterface, 5 | Shopify, 6 | } from "@shopify/shopify-api"; 7 | import { GraphqlClient } from "@shopify/shopify-api/dist/clients/graphql"; 8 | 9 | const RECURRING_INTERVALS = [ 10 | BillingInterval.Every30Days, 11 | BillingInterval.Annual, 12 | ]; 13 | 14 | let isProd: boolean; 15 | 16 | /** 17 | * You may want to charge merchants for using your app. This helper provides that function by checking if the current 18 | * merchant has an active one-time payment or subscription named `chargeName`. If no payment is found, 19 | * this helper requests it and returns a confirmation URL so that the merchant can approve the purchase. 20 | * 21 | * Learn more about billing in our documentation: https://shopify.dev/apps/billing 22 | */ 23 | export default async function ensureBilling( 24 | session: SessionInterface, 25 | { chargeName, amount, currencyCode, interval }: BillingSettings, 26 | isProdOverride = process.env.NODE_ENV === "production", 27 | ) { 28 | if (!Object.values(BillingInterval).includes(interval)) { 29 | throw `Unrecognized billing interval '${interval}'`; 30 | } 31 | 32 | isProd = isProdOverride; 33 | 34 | let hasPayment; 35 | let confirmationUrl = null; 36 | 37 | if (await hasActivePayment(session, { chargeName, interval })) { 38 | hasPayment = true; 39 | } else { 40 | hasPayment = false; 41 | confirmationUrl = await requestPayment(session, { 42 | chargeName, 43 | amount, 44 | currencyCode, 45 | interval, 46 | }); 47 | } 48 | 49 | return [hasPayment, confirmationUrl]; 50 | } 51 | 52 | async function hasActivePayment( 53 | session: SessionInterface, 54 | { chargeName, interval }: Pick, 55 | ) { 56 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 57 | 58 | if (isRecurring(interval)) { 59 | const currentInstallations = await client.query<{ 60 | data: { 61 | currentAppInstallation: { 62 | activeSubscriptions: { name: string; test: boolean }[]; 63 | }; 64 | }; 65 | }>({ 66 | data: RECURRING_PURCHASES_QUERY, 67 | }); 68 | const subscriptions = 69 | currentInstallations.body.data.currentAppInstallation.activeSubscriptions; 70 | 71 | for (let i = 0, len = subscriptions.length; i < len; i++) { 72 | if ( 73 | subscriptions[i].name === chargeName && 74 | (!isProd || !subscriptions[i].test) 75 | ) { 76 | return true; 77 | } 78 | } 79 | } else { 80 | let purchases; 81 | let endCursor = null; 82 | do { 83 | // @ts-ignore 84 | const currentInstallations = await client.query({ 85 | data: { 86 | query: ONE_TIME_PURCHASES_QUERY, 87 | variables: { endCursor }, 88 | }, 89 | }); 90 | purchases = 91 | currentInstallations.body.data.currentAppInstallation.oneTimePurchases; 92 | 93 | for (let i = 0, len = purchases.edges.length; i < len; i++) { 94 | const node = purchases.edges[i].node; 95 | if ( 96 | node.name === chargeName && 97 | (!isProd || !node.test) && 98 | node.status === "ACTIVE" 99 | ) { 100 | return true; 101 | } 102 | } 103 | 104 | endCursor = purchases.pageInfo.endCursor; 105 | } while (purchases.pageInfo.hasNextPage); 106 | } 107 | 108 | return false; 109 | } 110 | 111 | async function requestPayment( 112 | session: SessionInterface, 113 | { chargeName, amount, currencyCode, interval }: BillingSettings, 114 | ) { 115 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken); 116 | const returnUrl = `https://${Shopify.Context.HOST_NAME}?shop=${ 117 | session.shop 118 | }&host=${Buffer.from(`${session.shop}/admin`).toString("base64")}`; 119 | 120 | let data; 121 | if (isRecurring(interval)) { 122 | const mutationResponse = await requestRecurringPayment(client, returnUrl, { 123 | chargeName, 124 | amount, 125 | currencyCode, 126 | interval, 127 | }); 128 | data = mutationResponse.body.data.appSubscriptionCreate; 129 | } else { 130 | const mutationResponse = await requestSinglePayment(client, returnUrl, { 131 | chargeName, 132 | amount, 133 | currencyCode, 134 | }); 135 | data = mutationResponse.body.data.appPurchaseOneTimeCreate; 136 | } 137 | 138 | if (data.userErrors.length) { 139 | throw new ShopifyBillingError( 140 | "Error while billing the store", 141 | data.userErrors, 142 | ); 143 | } 144 | 145 | return data.confirmationUrl; 146 | } 147 | 148 | async function requestRecurringPayment( 149 | client: GraphqlClient, 150 | returnUrl: string, 151 | { chargeName, amount, currencyCode, interval }: BillingSettings, 152 | ) { 153 | const mutationResponse = await client.query<{ 154 | data: any; 155 | errors?: any[]; 156 | }>({ 157 | data: { 158 | query: RECURRING_PURCHASE_MUTATION, 159 | variables: { 160 | name: chargeName, 161 | lineItems: [ 162 | { 163 | plan: { 164 | appRecurringPricingDetails: { 165 | interval, 166 | price: { amount, currencyCode }, 167 | }, 168 | }, 169 | }, 170 | ], 171 | returnUrl, 172 | test: !isProd, 173 | }, 174 | }, 175 | }); 176 | 177 | if (mutationResponse.body.errors && mutationResponse.body.errors.length) { 178 | throw new ShopifyBillingError( 179 | "Error while billing the store", 180 | mutationResponse.body.errors, 181 | ); 182 | } 183 | 184 | return mutationResponse; 185 | } 186 | 187 | async function requestSinglePayment( 188 | client: GraphqlClient, 189 | returnUrl: string, 190 | { chargeName, amount, currencyCode }: Partial, 191 | ) { 192 | const mutationResponse = await client.query<{ 193 | data: any; 194 | errors?: any[]; 195 | }>({ 196 | data: { 197 | query: ONE_TIME_PURCHASE_MUTATION, 198 | variables: { 199 | name: chargeName, 200 | price: { amount, currencyCode }, 201 | returnUrl, 202 | test: process.env.NODE_ENV !== "production", 203 | }, 204 | }, 205 | }); 206 | 207 | if (mutationResponse.body.errors && mutationResponse.body.errors.length) { 208 | throw new ShopifyBillingError( 209 | "Error while billing the store", 210 | mutationResponse.body.errors, 211 | ); 212 | } 213 | 214 | return mutationResponse; 215 | } 216 | 217 | function isRecurring(interval: BillingInterval) { 218 | return RECURRING_INTERVALS.includes(interval); 219 | } 220 | 221 | export class ShopifyBillingError extends Error { 222 | constructor(message: string, public errorData: any[]) { 223 | super(message); 224 | this.name = "ShopifyBillingError"; 225 | this.stack = new Error().stack; 226 | this.message = message; 227 | this.errorData = errorData; 228 | } 229 | } 230 | 231 | const RECURRING_PURCHASES_QUERY = ` 232 | query appSubscription { 233 | currentAppInstallation { 234 | activeSubscriptions { 235 | name, test 236 | } 237 | } 238 | } 239 | `; 240 | 241 | const ONE_TIME_PURCHASES_QUERY = ` 242 | query appPurchases($endCursor: String) { 243 | currentAppInstallation { 244 | oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) { 245 | edges { 246 | node { 247 | name, test, status 248 | } 249 | } 250 | pageInfo { 251 | hasNextPage, endCursor 252 | } 253 | } 254 | } 255 | } 256 | `; 257 | 258 | const RECURRING_PURCHASE_MUTATION = ` 259 | mutation test( 260 | $name: String! 261 | $lineItems: [AppSubscriptionLineItemInput!]! 262 | $returnUrl: URL! 263 | $test: Boolean 264 | ) { 265 | appSubscriptionCreate( 266 | name: $name 267 | lineItems: $lineItems 268 | returnUrl: $returnUrl 269 | test: $test 270 | ) { 271 | confirmationUrl 272 | userErrors { 273 | field 274 | message 275 | } 276 | } 277 | } 278 | `; 279 | 280 | const ONE_TIME_PURCHASE_MUTATION = ` 281 | mutation test( 282 | $name: String! 283 | $price: MoneyInput! 284 | $returnUrl: URL! 285 | $test: Boolean 286 | ) { 287 | appPurchaseOneTimeCreate( 288 | name: $name 289 | price: $price 290 | returnUrl: $returnUrl 291 | test: $test 292 | ) { 293 | confirmationUrl 294 | userErrors { 295 | field 296 | message 297 | } 298 | } 299 | } 300 | `; 301 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ESNext", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["./**/*.ts"] 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify App Template - Node Typescript 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-green.svg?style=flat-square)](#contributors) 5 | 6 | 7 | 8 | This is a template for building a [Shopify app](https://shopify.dev/apps/getting-started) using Node and React using Typescript. It contains the basics for building a Shopify app. 9 | 10 | ## Contributors 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
Kai Spencer
Kai Spencer

💻
Cyril CHAPON
Cyril CHAPON

💻
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | # Original Repo Readme below 38 | 39 | ## Benefits 40 | 41 | Shopify apps are built on a variety of Shopify tools to create a great merchant experience. The [create an app](https://shopify.dev/apps/getting-started/create) tutorial in our developer documentation will guide you through creating a Shopify app using this template. 42 | 43 | The Node app template comes with the following out-of-the-box functionality: 44 | 45 | - OAuth: Installing the app and granting permissions 46 | - GraphQL Admin API: Querying or mutating Shopify admin data 47 | - REST Admin API: Resource classes to interact with the API 48 | - Shopify-specific tooling: 49 | - AppBridge 50 | - Polaris 51 | - Webhooks 52 | 53 | ## Tech Stack 54 | 55 | This template combines a number of third party open-source tools: 56 | 57 | - [Express](https://expressjs.com/) builds the backend. 58 | - [Vite](https://vitejs.dev/) builds the [React](https://reactjs.org/) frontend. 59 | - [React Router](https://reactrouter.com/) is used for routing. We wrap this with file-based routing. 60 | - [React Query](https://react-query.tanstack.com/) queries the Admin API. 61 | 62 | The following Shopify tools complement these third-party tools to ease app development: 63 | 64 | - [Shopify API library](https://github.com/Shopify/shopify-node-api) adds OAuth to the Express backend. This lets users install the app and grant scope permissions. 65 | - [App Bridge React](https://shopify.dev/apps/tools/app-bridge/getting-started/using-react) adds authentication to API requests in the frontend and renders components outside of the App’s iFrame. 66 | - [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants. 67 | - [Custom hooks](https://github.com/Shopify/shopify-frontend-template-react/tree/main/hooks) make authenticated requests to the Admin API. 68 | - [File-based routing](https://github.com/Shopify/shopify-frontend-template-react/blob/main/Routes.jsx) makes creating new pages easier. 69 | 70 | ## Getting started 71 | 72 | ### Requirements 73 | 74 | 1. You must [download and install Node.js](https://nodejs.org/en/download/) if you don't already have it. 75 | 1. You must [create a Shopify partner account](https://partners.shopify.com/signup) if you don’t have one. 76 | 1. You must [create a development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) if you don’t have one. 77 | 78 | ### Installing the template 79 | 80 | Clone this repository :D 81 | 82 | #### Local Development 83 | 84 | [The Shopify CLI](https://shopify.dev/apps/tools/cli) connects to an app in your Partners dashboard. It provides environment variables, runs commands in parallel, and updates application URLs for easier development. 85 | 86 | You can develop locally using your preferred package manager. Run one of the following commands from the root of your app. 87 | 88 | Using yarn: 89 | 90 | ```shell 91 | yarn dev 92 | ``` 93 | 94 | Using npm: 95 | 96 | ```shell 97 | npm run dev 98 | ``` 99 | 100 | Using pnpm: 101 | 102 | ```shell 103 | pnpm run dev 104 | ``` 105 | 106 | Open the URL generated in your console. Once you grant permission to the app, you can start development. 107 | 108 | ## Deployment 109 | 110 | ### Application Storage 111 | 112 | This template uses [SQLite](https://www.sqlite.org/index.html) to store session data. The database is a file called `database.sqlite` which is automatically created in the root. This use of SQLite works in production if your app runs as a single instance. 113 | 114 | The database that works best for you depends on the data your app needs and how it is queried. You can run your database of choice on a server yourself or host it with a SaaS company. Here’s a short list of databases providers that provide a free tier to get started: 115 | 116 | | Database | Type | Hosters | 117 | | ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 118 | | MySQL | SQL | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-mysql), [Planet Scale](https://planetscale.com/), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/mysql) | 119 | | PostgreSQL | SQL | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-postgresql), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/postgres) | 120 | | Redis | Key-value | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-redis), [Amazon MemoryDB](https://aws.amazon.com/memorydb/) | 121 | | MongoDB | NoSQL / Document | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-mongodb), [MongoDB Atlas](https://www.mongodb.com/atlas/database) | 122 | 123 | To use one of these, you need to change your session storage configuration. To help, here’s a list of [SessionStorage adapters](https://github.com/Shopify/shopify-api-node/tree/main/src/auth/session/storage). 124 | 125 | ### Build 126 | 127 | The frontend is a single page app. It requires the `SHOPIFY_API_KEY`, which you can find on the page for your app in your partners dashboard. Paste your app’s key in the command for the package manager of your choice: 128 | 129 | Using yarn: 130 | 131 | ```shell 132 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME yarn build 133 | ``` 134 | 135 | Using npm: 136 | 137 | ```shell 138 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME npm run build 139 | ``` 140 | 141 | Using pnpm: 142 | 143 | ```shell 144 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME pnpm run build 145 | ``` 146 | 147 | You do not need to build the backend. 148 | 149 | ## Hosting 150 | 151 | When you're ready to set up your app in production, you can follow [our deployment documentation](https://shopify.dev/apps/deployment/web) to host your app on a cloud provider like [Heroku](https://www.heroku.com/) or [Fly.io](https://fly.io/). 152 | 153 | When you reach the step for [setting up environment variables](https://shopify.dev/apps/deployment/web#set-env-vars), you also need to set the variable `NODE_ENV=production`. 154 | 155 | ## Some things to watch out for 156 | 157 | ### Using `express.json` middleware 158 | 159 | If you use the `express.json()` middleware in your app **and** if you use `Shopify.Webhooks.Registry.process()` to process webhooks API calls from Shopify (which we recommend), the webhook processing must occur **_before_** calling `app.use(express.json())`. See the [API documentation](https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers) for more details. 160 | 161 | ## Known issues 162 | 163 | ### Hot module replacement and Firefox 164 | 165 | When running the app with the CLI in development mode on Firefox, you might see your app constantly reloading when you access it. 166 | That happened in previous versions of the CLI, because of the way HMR websocket requests work. 167 | 168 | We fixed this issue with v3.4.0 of the CLI, so after updating it, you can make the following changes to your app's `web/frontend/vite.config.js` file: 169 | 170 | 1. Change the definition `hmrConfig` object to be: 171 | 172 | ```js 173 | const host = process.env.HOST 174 | ? process.env.HOST.replace(/https?:\/\//, "") 175 | : "localhost"; 176 | 177 | let hmrConfig; 178 | if (host === "localhost") { 179 | hmrConfig = { 180 | protocol: "ws", 181 | host: "localhost", 182 | port: 64999, 183 | clientPort: 64999, 184 | }; 185 | } else { 186 | hmrConfig = { 187 | protocol: "wss", 188 | host: host, 189 | port: process.env.FRONTEND_PORT, 190 | clientPort: 443, 191 | }; 192 | } 193 | ``` 194 | 195 | 1. Change the `server.host` setting in the configs to `"localhost"`: 196 | 197 | ```js 198 | server: { 199 | host: "localhost", 200 | ... 201 | ``` 202 | 203 | ### I can't get past the ngrok "Visit site" page 204 | 205 | When you’re previewing your app or extension, you might see an ngrok interstitial page with a warning: 206 | 207 | ```text 208 | You are about to visit .ngrok.io: Visit Site 209 | ``` 210 | 211 | If you click the `Visit Site` button, but continue to see this page, then you should run dev using an alternate tunnel URL that you run using tunneling software. 212 | We've validated that [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/trycloudflare/) works with this template. 213 | 214 | To do that, you can [install the `cloudflared` CLI tool](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/), and run: 215 | 216 | ```shell 217 | # Note that you can also use a different port 218 | cloudflared tunnel --url http://localhost:3000 219 | ``` 220 | 221 | In a different terminal window, navigate to your app's root and call: 222 | 223 | ```shell 224 | # Using yarn 225 | yarn dev --tunnel-url https://tunnel-url:3000 226 | # or using npm 227 | npm run dev --tunnel-url https://tunnel-url:3000 228 | # or using pnpm 229 | pnpm dev --tunnel-url https://tunnel-url:3000 230 | ``` 231 | 232 | ## Developer resources 233 | 234 | - [Introduction to Shopify apps](https://shopify.dev/apps/getting-started) 235 | - [App authentication](https://shopify.dev/apps/auth) 236 | - [Shopify CLI](https://shopify.dev/apps/tools/cli) 237 | - [Shopify API Library documentation](https://github.com/Shopify/shopify-api-node/tree/main/docs) 238 | --------------------------------------------------------------------------------