├── .changeset ├── README.md ├── config.json └── famous-news-start.md ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md └── workflows │ ├── assign-pr.yml │ ├── dev-release.yml │ ├── main.yml │ └── prepare-release.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── codecov.yml ├── package.json ├── pnpm-lock.yaml ├── src ├── APL │ ├── apl-debug.ts │ ├── apl.ts │ ├── auth-data-from-object.ts │ ├── env │ │ ├── env-apl.test.ts │ │ ├── env-apl.ts │ │ └── index.ts │ ├── file │ │ ├── file-apl.test.ts │ │ ├── file-apl.ts │ │ └── index.ts │ ├── has-auth-data.ts │ ├── index.ts │ ├── redis │ │ ├── index.ts │ │ ├── redis-apl.test.ts │ │ └── redis-apl.ts │ ├── saleor-cloud │ │ ├── index.ts │ │ ├── paginator.test.ts │ │ ├── paginator.ts │ │ ├── saleor-cloud-apl-errors.ts │ │ ├── saleor-cloud-apl.test.ts │ │ └── saleor-cloud-apl.ts │ ├── upstash │ │ ├── index.ts │ │ ├── upstash-apl.test.ts │ │ └── upstash-apl.ts │ └── vercel-kv │ │ ├── index.ts │ │ ├── vercel-kv-apl.test.ts │ │ └── vercel-kv-apl.ts ├── app-bridge │ ├── actions.test.ts │ ├── actions.ts │ ├── app-bridge-provider.test.tsx │ ├── app-bridge-provider.tsx │ ├── app-bridge-state.test.ts │ ├── app-bridge-state.ts │ ├── app-bridge.test.ts │ ├── app-bridge.ts │ ├── app-iframe-params.ts │ ├── constants.ts │ ├── events.test.ts │ ├── events.ts │ ├── fetch.test.ts │ ├── fetch.ts │ ├── helpers.ts │ ├── index.ts │ ├── next │ │ ├── index.ts │ │ └── route-propagator.tsx │ ├── types.ts │ ├── use-dashboard-token.ts │ └── with-authorization.tsx ├── auth │ ├── fetch-remote-jwks.ts │ ├── has-permissions-in-jwt-token.ts │ ├── has-permissions.in-jwt-token.test.ts │ ├── index.ts │ ├── verify-jwt.test.ts │ ├── verify-jwt.ts │ ├── verify-signature.test.ts │ ├── verify-signature.ts │ ├── verify-token-expiration.test.ts │ └── verify-token-expiration.ts ├── debug.ts ├── get-app-id.ts ├── gql-ast-to-string.ts ├── handlers │ ├── actions │ │ ├── manifest-action-handler.test.ts │ │ ├── manifest-action-handler.ts │ │ ├── register-action-handler.test.ts │ │ └── register-action-handler.ts │ ├── platforms │ │ ├── aws-lambda │ │ │ ├── create-app-register-handler.test.ts │ │ │ ├── create-app-register-handler.ts │ │ │ ├── create-manifest-handler.test.ts │ │ │ ├── create-manifest-handler.ts │ │ │ ├── create-protected-handler.test.ts │ │ │ ├── create-protected-handler.ts │ │ │ ├── index.ts │ │ │ ├── platform-adapter.test.ts │ │ │ ├── platform-adapter.ts │ │ │ ├── saleor-webhooks │ │ │ │ ├── saleor-async-webhook.ts │ │ │ │ ├── saleor-sync-webhook.test.ts │ │ │ │ ├── saleor-sync-webhook.ts │ │ │ │ └── saleor-webhook.ts │ │ │ └── test-utils.ts │ │ ├── fetch-api │ │ │ ├── create-app-register-handler.test.ts │ │ │ ├── create-app-register-handler.ts │ │ │ ├── create-manifest-handler.test.ts │ │ │ ├── create-manifest-handler.ts │ │ │ ├── create-protected-handler.test.ts │ │ │ ├── create-protected-handler.ts │ │ │ ├── index.ts │ │ │ ├── platform-adapter.test.ts │ │ │ ├── platform-adapter.ts │ │ │ └── saleor-webhooks │ │ │ │ ├── saleor-async-webhook.test.ts │ │ │ │ ├── saleor-async-webhook.ts │ │ │ │ ├── saleor-sync-webhook.test.ts │ │ │ │ ├── saleor-sync-webhook.ts │ │ │ │ └── saleor-webhook.ts │ │ ├── next-app-router │ │ │ ├── create-app-register-handler.ts │ │ │ ├── create-manifest-handler.ts │ │ │ ├── create-protected-handler.ts │ │ │ ├── index.ts │ │ │ ├── platform-adapter.ts │ │ │ └── saleor-webhooks │ │ │ │ ├── saleor-async-webhook.ts │ │ │ │ ├── saleor-sync-webhook.test.ts │ │ │ │ ├── saleor-sync-webhook.ts │ │ │ │ └── saleor-webhook.ts │ │ └── next │ │ │ ├── create-app-register-handler.test.ts │ │ │ ├── create-app-register-handler.ts │ │ │ ├── create-manifest-handler.test.ts │ │ │ ├── create-manifest-handler.ts │ │ │ ├── create-protected-handler.test.ts │ │ │ ├── create-protected-handler.ts │ │ │ ├── index.ts │ │ │ ├── platform-adapter.test.ts │ │ │ ├── platform-adapter.ts │ │ │ └── saleor-webhooks │ │ │ ├── saleor-async-webhook.test.ts │ │ │ ├── saleor-async-webhook.ts │ │ │ ├── saleor-sync-webhook.test.ts │ │ │ ├── saleor-sync-webhook.ts │ │ │ └── saleor-webhook.ts │ ├── readme.md │ └── shared │ │ ├── create-app-register-handler-types.ts │ │ ├── generic-adapter-use-case-types.ts │ │ ├── generic-saleor-webhook.ts │ │ ├── index.ts │ │ ├── protected-action-validator.test.ts │ │ ├── protected-action-validator.ts │ │ ├── protected-handler.ts │ │ ├── saleor-request-processor.test.ts │ │ ├── saleor-request-processor.ts │ │ ├── saleor-webhook-validator.test.ts │ │ ├── saleor-webhook-validator.ts │ │ ├── saleor-webhook.ts │ │ ├── sync-webhook-response-builder.ts │ │ ├── validate-allow-saleor-urls.test.ts │ │ └── validate-allow-saleor-urls.ts ├── has-prop.ts ├── headers.ts ├── locales.ts ├── open-telemetry.ts ├── saleor-app.test.ts ├── saleor-app.ts ├── settings-manager │ ├── encrypted-metadata-manager.test.ts │ ├── encrypted-metadata-manager.ts │ ├── index.ts │ ├── metadata-manager.test.ts │ ├── metadata-manager.ts │ └── settings-manager.ts ├── setup-tests.ts ├── test-utils │ ├── mock-adapter.ts │ └── mock-apl.ts ├── types.ts └── util │ ├── extract-app-permissions-from-jwt.test.ts │ ├── extract-app-permissions-from-jwt.ts │ ├── extract-user-from-jwt.test.ts │ ├── extract-user-from-jwt.ts │ ├── is-in-iframe.ts │ ├── public │ ├── browser │ │ └── index.ts │ └── index.ts │ ├── schema-version.test.ts │ ├── schema-version.ts │ └── use-is-mounted.ts ├── test └── integration │ └── redis-apl.test.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/famous-news-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@saleor/app-sdk": patch 3 | --- 4 | 5 | Added custom warning for not available Crypto in non-secure environments (it can be used in localhost or https only) 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "tsconfigRootDir": "./", 5 | "project": ["./tsconfig.json"] 6 | }, 7 | "extends": [ 8 | "airbnb", 9 | "airbnb-typescript", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" // prettier *has* to be the last one, to avoid conflicting rules 12 | ], 13 | "ignorePatterns": ["pnpm-lock.yaml", "dist", "coverage"], 14 | "plugins": ["simple-import-sort", "@typescript-eslint"], 15 | "rules": { 16 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 17 | "quotes": ["error", "double"], 18 | "react/react-in-jsx-scope": "off", // next does not require react imports 19 | "import/extensions": "off", // file extension not required when importing 20 | "react/jsx-filename-extension": "off", 21 | "no-restricted-syntax": [ 22 | "error", 23 | { 24 | "selector": "ForInStatement", 25 | "message": "for ... in disallowed, use for ... of instead" 26 | } 27 | ], 28 | 29 | "no-underscore-dangle": "off", 30 | "no-await-in-loop": "off", 31 | "react/jsx-props-no-spreading": "off", 32 | "react/require-default-props": "off", 33 | "simple-import-sort/imports": "warn", 34 | "simple-import-sort/exports": "warn", 35 | "import/first": "warn", 36 | "import/newline-after-import": "warn", 37 | "import/no-duplicates": "warn", 38 | "no-unused-vars": "off", 39 | "@typescript-eslint/no-unused-vars": ["error"], 40 | "@typescript-eslint/ban-types": "off", 41 | "no-console": [ 42 | "error", 43 | { 44 | "allow": ["warn", "error", "debug"] 45 | } 46 | ], 47 | "no-continue": "off", 48 | "operator-linebreak": "off", 49 | "max-len": "off", 50 | "array-callback-return": "off", 51 | "implicit-arrow-linebreak": "off", 52 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 53 | "@typescript-eslint/no-non-null-assertion": "off", 54 | "no-restricted-imports": "off", 55 | "no-restricted-exports": "off", 56 | "@typescript-eslint/ban-ts-comment": "off", 57 | // TO FIX: 58 | "import/no-cycle": "off", // pathpidia issue 59 | "import/prefer-default-export": "off", 60 | "@typescript-eslint/no-misused-promises": ["error"], 61 | "@typescript-eslint/no-floating-promises": ["error"], 62 | "class-methods-use-this": "off", 63 | "no-new": "off", 64 | "@typescript-eslint/no-redeclare": "off", 65 | "@typescript-eslint/naming-convention": "off" 66 | }, 67 | "settings": { 68 | "import/parsers": { 69 | "@typescript-eslint/parser": [".ts", ".tsx"] 70 | }, 71 | "import/resolver": { 72 | "typescript": { 73 | "alwaysTryTypes": true // always try to resolve types under `@types` directory even it doesn't contain any source code, like `@types/unist` 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea 4 | title: "" 5 | labels: Feature request 6 | assignees: "lkostrowski" 7 | --- 8 | 9 | ### What I'm trying to achieve 10 | 11 | … 12 | 13 | ### Describe a proposed solution 14 | 15 | ... 16 | 17 | ### Other solutions I've tried and won't work 18 | 19 | … 20 | 21 | ### Screenshots or mockups 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/assign-pr.yml: -------------------------------------------------------------------------------- 1 | name: Assign PR to creator 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | 7 | jobs: 8 | assign_creator: 9 | if: ${{ github.actor != 'dependabot[bot]' }} 10 | runs-on: ubuntu-22.04 11 | env: 12 | HUSKY: 0 13 | steps: 14 | - name: Assign PR to creator 15 | uses: toshimaru/auto-author-assign@ebd30f10fb56e46eb0759a14951f36991426fed0 # v2.1.0 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/dev-release.yml: -------------------------------------------------------------------------------- 1 | name: Release @dev tag to npm 2 | on: 3 | pull_request: 4 | types: [labeled] 5 | 6 | jobs: 7 | release: 8 | if: ${{ github.event.label.name == 'release dev tag' }} 9 | runs-on: ubuntu-22.04 10 | env: 11 | HUSKY: 0 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Setup PNPM 17 | uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 18 | with: 19 | run_install: | 20 | - args: [--frozen-lockfile] 21 | - name: Check for changeset 22 | run: pnpm exec changeset status --since origin/main 23 | - name: Create .npmrc 24 | run: | 25 | cat << EOF > "$HOME/.npmrc" 26 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 27 | EOF 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | - name: Release on @dev tag in npm 31 | run: pnpm publish:ci-dev 32 | - name: Get new package version 33 | run: | 34 | VERSION=$(cat package.json | jq -r '.version') 35 | echo "VERSION=$VERSION" >> "$GITHUB_ENV" 36 | - name: Add installation instructions PR comment 37 | uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 38 | env: 39 | VERSION: ${{ env.VERSION }} 40 | with: 41 | issue-number: ${{ github.event.pull_request.number }} 42 | body: | 43 | Released snapshot build with `@dev` tag in npm with version: `${{ env.VERSION }}`. 44 | 45 | Install it with: 46 | ```shell 47 | pnpm add @saleor/app-sdk@${{ env.VERSION }} 48 | ``` 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | on: 3 | pull_request: 4 | types: 5 | - synchronize 6 | - opened 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-22.04 14 | env: 15 | HUSKY: 0 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup PNPM 19 | uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 20 | with: 21 | run_install: false 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version-file: ".nvmrc" 25 | cache: "pnpm" 26 | - name: Install dependencies 27 | run: pnpm install --frozen-lockfile 28 | - name: Check linter 29 | run: pnpm lint 30 | - name: Check types 31 | run: pnpm check-types 32 | 33 | test: 34 | runs-on: ubuntu-22.04 35 | env: 36 | HUSKY: 0 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Setup PNPM 40 | uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 41 | with: 42 | run_install: false 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version-file: ".nvmrc" 46 | cache: "pnpm" 47 | - name: Install dependencies 48 | run: pnpm install --frozen-lockfile 49 | - name: Run tests 50 | run: pnpm test:ci 51 | - name: Upload coverage reports to Codecov 52 | uses: codecov/codecov-action@0da7aa657d958d32c117fc47e1f977e7524753c7 # v5.3.0 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | slug: saleor/app-sdk 56 | 57 | build: 58 | runs-on: ubuntu-22.04 59 | env: 60 | HUSKY: 0 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: Setup PNPM 64 | uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 65 | with: 66 | run_install: false 67 | - uses: actions/setup-node@v4 68 | with: 69 | node-version-file: ".nvmrc" 70 | cache: "pnpm" 71 | - name: Install dependencies 72 | run: pnpm install --frozen-lockfile 73 | - name: Build package 74 | run: pnpm build 75 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | name: Open release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "v[0-9]+.x" 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | name: Prepare release with Changesets 14 | runs-on: ubuntu-22.04 15 | env: 16 | HUSKY: 0 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | token: ${{ secrets.PAT }} 21 | - name: Setup PNPM 22 | uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 23 | with: 24 | run_install: | 25 | - args: [--frozen-lockfile] 26 | - name: Create Release Pull Request 27 | uses: changesets/action@e2f8e964d080ae97c874b19e27b12e0a8620fb6c # v1.4.6 28 | with: 29 | title: Release to npm 30 | commit: Release to npm 31 | publish: "pnpm publish:ci-prod" 32 | env: 33 | # Use private access token so Github can trigger another workflow from this one 34 | GITHUB_TOKEN: ${{ secrets.PAT }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | .idea/ 119 | .vscode/ 120 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm run lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm check-types && pnpm test:ci 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | test.ts 3 | bare.ts 4 | *.tgz 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | manage-package-manager-versions=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.11 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | saleor/api.tsx 3 | pnpm-lock.yaml 4 | graphql.schema.json 5 | lib/$path.ts 6 | dist 7 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @saleor/extensibility-team-js 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020-2025, Saleor Commerce 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Discord Badge](https://dcbadge.vercel.app/api/server/unUfh24R6d)](https://discord.gg/unUfh24R6d) 4 | 5 |
6 | 7 | # SDK for Saleor Apps 8 | 9 | SDK for building [Saleor Apps](https://github.com/saleor/apps). 10 | 11 | Supports Saleor version 3.20+ 12 | 13 |
14 | 15 | [![npm version badge](https://img.shields.io/npm/v/@saleor/app-sdk)](https://www.npmjs.com/package/@saleor/app-sdk) 16 | [![npm downloads count](https://img.shields.io/npm/dt/@saleor/app-sdk)](https://www.npmjs.com/package/@saleor/app-sdk) 17 | 18 |
19 | 20 | ## Release flow 21 | 22 | - The `main` branch is a current, latest branch. 23 | - Branches matching `v[0-9]+.x` (like `v1.x`, v0.x`) are release branches 24 | - PRs should be opened to `main` branch and contain changesets (run `npx changeset`). Once changeset is merged to main, the release PR is opened. After the release PR is merged, the version is being pushed to NPM and changesets are pruned 25 | - To patch older version, commit from `main` (including changeset) should be also ported to release branch (e.g. v0.x). Release branch will also detect changes and open release PR 26 | - To release new major version (e.g. start working on `v2.x` from `v1.x`): 27 | - Create a legacy release branch (e.g. `v1.x` branch) 28 | - Mark changeset to `main` with `major` change, which will start counting next `main` releases as `2.x.x` 29 | - Do not merge release PR until it's ready to be merged 30 | 31 | ### Deploying test snapshots 32 | 33 | PRs can be pushed to NPM by adding label to PR `release dev tag`. Workflow will run and print version that has been released. 34 | 35 | ## Installing 36 | 37 | ```bash 38 | npm i @saleor/app-sdk 39 | ``` 40 | 41 | ## Docs 42 | 43 | You can find the documentation [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-apps/app-sdk/overview). 44 | 45 | ## Development 46 | 47 | ### How to link development version to your project 48 | 49 | If you would like to develop the SDK and test it with existing project: 50 | 51 | 1. In the Saleor App SDK directory run command 52 | 53 | ```bash 54 | pnpm watch 55 | ``` 56 | 57 | Now any code change will trigger build operation automatically. 58 | 59 | 2. In your project directory: 60 | 61 | ```bash 62 | pnpm add ../saleor-app-sdk/dist 63 | ``` 64 | 65 | As path to your local copy of the App SDK may be different, adjust it accordingly. 66 | 67 | ### Code style 68 | 69 | Before committing the code, Git pre-hooks will check staged changes for 70 | following the code styles. If you would like to format the code by yourself, run 71 | the command: 72 | 73 | ```bash 74 | pnpm lint 75 | ``` 76 | 77 | ### Running Integration Tests 78 | 79 | To run the integration tests (e.g., Redis APL tests), follow these steps: 80 | 81 | 1. Start a Redis container: 82 | 83 | ```bash 84 | docker run --name saleor-app-sdk-redis -p 6379:6379 -d redis:7-alpine 85 | ``` 86 | 87 | 2. Run the integration tests: 88 | 89 | ```bash 90 | pnpm test:integration 91 | ``` 92 | 93 | 3. (Optional) Clean up the Redis container: 94 | 95 | ```bash 96 | docker stop saleor-app-sdk-redis 97 | docker rm saleor-app-sdk-redis 98 | ``` 99 | 100 | Note: If your Redis instance is running on a different host or port, you can set the `REDIS_URL` environment variable: 101 | 102 | ```bash 103 | REDIS_URL=redis://custom-host:6379 pnpm test:integration 104 | ``` 105 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: 5 | informational: true 6 | project: 7 | default: 8 | informational: true 9 | -------------------------------------------------------------------------------- /src/APL/apl-debug.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "../debug"; 2 | 3 | export const createAPLDebug = (namespace: string) => createDebug(`APL:${namespace}`); 4 | -------------------------------------------------------------------------------- /src/APL/apl.ts: -------------------------------------------------------------------------------- 1 | export interface AuthData { 2 | token: string; 3 | saleorApiUrl: string; 4 | appId: string; 5 | jwks?: string; 6 | } 7 | 8 | export type AplReadyResult = 9 | | { 10 | ready: true; 11 | } 12 | | { 13 | ready: false; 14 | error: Error; 15 | }; 16 | 17 | export type AplConfiguredResult = 18 | | { 19 | configured: true; 20 | } 21 | | { 22 | configured: false; 23 | error: Error; 24 | }; 25 | 26 | export interface APL { 27 | get: (saleorApiUrl: string) => Promise; 28 | set: (authData: AuthData) => Promise; 29 | delete: (saleorApiUrl: string) => Promise; 30 | getAll: () => Promise; 31 | /** 32 | * Inform that configuration is finished and correct 33 | */ 34 | isReady?: () => Promise; 35 | isConfigured?: () => Promise; 36 | } 37 | -------------------------------------------------------------------------------- /src/APL/auth-data-from-object.ts: -------------------------------------------------------------------------------- 1 | import { AuthData } from "./apl"; 2 | import { createAPLDebug } from "./apl-debug"; 3 | import { hasAuthData } from "./has-auth-data"; 4 | 5 | const debug = createAPLDebug("authDataFromObject"); 6 | 7 | /** 8 | * Returns AuthData if the object follows it's structure 9 | */ 10 | export const authDataFromObject = (parsed: unknown): AuthData | undefined => { 11 | if (!hasAuthData(parsed)) { 12 | debug("Given object did not contained AuthData"); 13 | return undefined; 14 | } 15 | const { saleorApiUrl, appId, token, jwks } = parsed as AuthData; 16 | return { 17 | saleorApiUrl, 18 | appId, 19 | token, 20 | jwks, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/APL/env/env-apl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { AuthData } from "../apl"; 4 | import { EnvAPL } from "./env-apl"; 5 | 6 | const getMockEnvVars = () => ({ 7 | SALEOR_APP_TOKEN: "some-token", 8 | SALEOR_APP_ID: "app-id", 9 | SALEOR_API_URL: "https://my-saleor-instance.cloud/graphql/", 10 | }); 11 | 12 | const getMockAuthData = (): AuthData => ({ 13 | saleorApiUrl: "https://my-saleor-instance.cloud/graphql/", 14 | appId: "app-id", 15 | token: "some-token", 16 | jwks: "{}", 17 | }); 18 | 19 | describe("EnvAPL", () => { 20 | it("Constructs when values are provided in constructor", () => { 21 | const envVars = getMockEnvVars(); 22 | 23 | expect( 24 | new EnvAPL({ 25 | env: { 26 | token: envVars.SALEOR_APP_TOKEN, 27 | appId: envVars.SALEOR_APP_ID, 28 | saleorApiUrl: envVars.SALEOR_API_URL, 29 | }, 30 | }), 31 | ).toBeDefined(); 32 | }); 33 | 34 | it("Prints auth data from \"set\" method in stdout if printAuthDataOnRegister set to \"true\"", async () => { 35 | const envVars = getMockEnvVars(); 36 | 37 | vi.spyOn(console, "log"); 38 | 39 | const mockAuthData = getMockAuthData(); 40 | 41 | await new EnvAPL({ 42 | env: { 43 | token: envVars.SALEOR_APP_TOKEN, 44 | appId: envVars.SALEOR_APP_ID, 45 | saleorApiUrl: envVars.SALEOR_API_URL, 46 | }, 47 | printAuthDataOnRegister: true, 48 | }).set(mockAuthData); 49 | 50 | // eslint-disable-next-line no-console 51 | return expect(console.log).toHaveBeenNthCalledWith( 52 | 2, 53 | /** 54 | * Assert stringified values for formatting 55 | */ 56 | `{ 57 | "saleorApiUrl": "https://my-saleor-instance.cloud/graphql/", 58 | "appId": "app-id", 59 | "token": "some-token", 60 | "jwks": "{}" 61 | }`, 62 | ); 63 | }); 64 | 65 | it("Returns authData from constructor in get() and getAll()", async () => { 66 | const envVars = getMockEnvVars(); 67 | 68 | const apl = new EnvAPL({ 69 | env: { 70 | token: envVars.SALEOR_APP_TOKEN, 71 | appId: envVars.SALEOR_APP_ID, 72 | saleorApiUrl: envVars.SALEOR_API_URL, 73 | }, 74 | printAuthDataOnRegister: true, 75 | }); 76 | 77 | expect(await apl.get(envVars.SALEOR_API_URL)).toEqual({ 78 | token: envVars.SALEOR_APP_TOKEN, 79 | appId: envVars.SALEOR_APP_ID, 80 | saleorApiUrl: envVars.SALEOR_API_URL, 81 | }); 82 | 83 | expect(await apl.getAll()).toEqual([ 84 | { 85 | token: envVars.SALEOR_APP_TOKEN, 86 | appId: envVars.SALEOR_APP_ID, 87 | saleorApiUrl: envVars.SALEOR_API_URL, 88 | }, 89 | ]); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/APL/env/env-apl.ts: -------------------------------------------------------------------------------- 1 | import { APL, AuthData } from "../apl"; 2 | import { createAPLDebug } from "../apl-debug"; 3 | 4 | const debug = createAPLDebug("EnvAPL"); 5 | 6 | type AuthDataRequired = Omit; 7 | 8 | type Options = { 9 | env: Record; 10 | /** 11 | * Enable to log auth data to stdout. 12 | * Do it once to save data in ENV and disable it later. 13 | */ 14 | printAuthDataOnRegister?: boolean; 15 | }; 16 | 17 | export class EnvAPL implements APL { 18 | private defaultOptions: Partial = { 19 | printAuthDataOnRegister: false, 20 | }; 21 | 22 | options: Options; 23 | 24 | constructor(options: Options) { 25 | if (!this.isAuthDataValid(options.env)) { 26 | // eslint-disable-next-line no-console 27 | console.warn( 28 | "EnvAPL constructor not filled with valid AuthData config. Try to install the app with \"printAuthDataOnRegister\" enabled and check console logs", 29 | ); 30 | } 31 | 32 | this.options = { 33 | ...this.defaultOptions, 34 | ...options, 35 | }; 36 | } 37 | 38 | private isAuthDataValid(authData: AuthData): boolean { 39 | const keysToValidateAgainst: Array = ["appId", "saleorApiUrl", "token"]; 40 | 41 | return keysToValidateAgainst.every( 42 | (key) => authData[key] && typeof authData[key] === "string" && authData[key]!.length > 0, 43 | ); 44 | } 45 | 46 | async isReady() { 47 | return this.isAuthDataValid(this.options.env) 48 | ? ({ 49 | ready: true, 50 | } as const) 51 | : { 52 | ready: false, 53 | error: new Error("Auth data not valid, check constructor and pass env variables"), 54 | }; 55 | } 56 | 57 | /** 58 | * Always return its configured, because otherwise .set() will never be called 59 | * so env can't be printed 60 | */ 61 | async isConfigured() { 62 | return { 63 | configured: true, 64 | } as const; 65 | } 66 | 67 | async set(authData: AuthData) { 68 | if (this.options.printAuthDataOnRegister) { 69 | // eslint-disable-next-line no-console 70 | console.log("Displaying registration values for the app. Use them to configure EnvAPL"); 71 | // eslint-disable-next-line no-console 72 | console.log(JSON.stringify(authData, null, 2)); 73 | console.warn( 74 | "🛑'printAuthDataOnRegister' option should be turned off once APL is configured, to avoid possible leaks", 75 | ); 76 | } 77 | debug("Called set method"); 78 | } 79 | 80 | async get(saleorApiUrl: string) { 81 | if (!this.isAuthDataValid(this.options.env)) { 82 | debug("Trying to get AuthData but APL constructor was not filled with proper AuthData"); 83 | return undefined; 84 | } 85 | 86 | if (saleorApiUrl !== this.options.env.saleorApiUrl) { 87 | throw new Error( 88 | `Requested AuthData for domain "${saleorApiUrl}", however APL is configured for ${this.options.env.saleorApiUrl}. You may trying to install app in invalid Saleor URL `, 89 | ); 90 | } 91 | 92 | return this.options.env; 93 | } 94 | 95 | async getAll() { 96 | if (!this.isAuthDataValid(this.options.env)) { 97 | return []; 98 | } 99 | 100 | const authData = await this.get(this.options.env.saleorApiUrl); 101 | 102 | return authData ? [authData] : []; 103 | } 104 | 105 | async delete() { 106 | debug("Called delete method"); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/APL/env/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./env-apl"; 2 | -------------------------------------------------------------------------------- /src/APL/file/file-apl.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsPromises } from "fs"; 2 | 3 | import { APL, AuthData } from "../apl"; 4 | import { createAPLDebug } from "../apl-debug"; 5 | 6 | const debug = createAPLDebug("FileAPL"); 7 | 8 | export type FileAPLConfig = { 9 | fileName?: string; 10 | }; 11 | 12 | /** 13 | * File APL 14 | * 15 | * The APL store auth data in the json file. 16 | * 17 | * Before using this APL, please take in consideration: 18 | * - only stores single auth data entry (setting up a new one will overwrite previous values) 19 | * - it's not recommended for production use - redeployment of the application will override 20 | * existing values, or data persistence will not be guaranteed at all depending on chosen 21 | * hosting solution 22 | * 23 | */ 24 | export class FileAPL implements APL { 25 | private fileName: string; 26 | 27 | constructor(config: FileAPLConfig = {}) { 28 | this.fileName = config?.fileName || ".saleor-app-auth.json"; 29 | } 30 | 31 | /** 32 | * Load auth data from a file and return it as AuthData format. 33 | * In case of incomplete or invalid data, return `undefined`. 34 | */ 35 | private async loadDataFromFile(): Promise { 36 | debug(`Will try to load auth data from the ${this.fileName} file`); 37 | let parsedData: Record = {}; 38 | 39 | try { 40 | parsedData = JSON.parse(await fsPromises.readFile(this.fileName, "utf-8")); 41 | debug("%s read successfully", this.fileName); 42 | } catch (err) { 43 | debug(`Could not read auth data from the ${this.fileName} file`, err); 44 | debug( 45 | "Maybe apl.get() was called before app was registered. Returning empty, fallback data (undefined)", 46 | ); 47 | 48 | return undefined; 49 | } 50 | 51 | const { token, saleorApiUrl, appId, jwks } = parsedData; 52 | 53 | if (token && saleorApiUrl && appId) { 54 | debug("Token found, returning values: %s", `${token[0]}***`); 55 | 56 | const authData: AuthData = { token, saleorApiUrl, appId }; 57 | 58 | if (jwks) { 59 | authData.jwks = jwks; 60 | } 61 | 62 | return authData; 63 | } 64 | 65 | return undefined; 66 | } 67 | 68 | /** 69 | * Save auth data to file. 70 | * When `authData` argument is empty, will overwrite file with empty values. 71 | */ 72 | private async saveDataToFile(authData?: AuthData) { 73 | debug(`Trying to save auth data to the ${this.fileName} file`); 74 | 75 | const newData = authData ? JSON.stringify(authData) : "{}"; 76 | 77 | try { 78 | await fsPromises.writeFile(this.fileName, newData); 79 | 80 | debug("Successfully written file %", this.fileName); 81 | } catch (err) { 82 | debug(`Could not save auth data to the ${this.fileName} file`, err); 83 | throw new Error("File APL was unable to save auth data"); 84 | } 85 | } 86 | 87 | async get(saleorApiUrl: string) { 88 | const authData = await this.loadDataFromFile(); 89 | if (saleorApiUrl === authData?.saleorApiUrl) { 90 | return authData; 91 | } 92 | return undefined; 93 | } 94 | 95 | async set(authData: AuthData) { 96 | await this.saveDataToFile(authData); 97 | } 98 | 99 | async delete(saleorApiUrl: string) { 100 | const authData = await this.loadDataFromFile(); 101 | 102 | if (saleorApiUrl === authData?.saleorApiUrl) { 103 | await this.saveDataToFile(); 104 | } 105 | } 106 | 107 | async getAll() { 108 | const authData = await this.loadDataFromFile(); 109 | 110 | if (!authData) { 111 | return []; 112 | } 113 | 114 | return [authData]; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/APL/file/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./file-apl"; 2 | -------------------------------------------------------------------------------- /src/APL/has-auth-data.ts: -------------------------------------------------------------------------------- 1 | import { hasProp } from "../has-prop"; 2 | 3 | /** 4 | * Checks if given object has fields used by the AuthData 5 | */ 6 | export const hasAuthData = (data: unknown) => 7 | hasProp(data, "token") && 8 | data.token && 9 | hasProp(data, "appId") && 10 | data.appId && 11 | hasProp(data, "saleorApiUrl") && 12 | data.saleorApiUrl; 13 | -------------------------------------------------------------------------------- /src/APL/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./apl"; 2 | -------------------------------------------------------------------------------- /src/APL/redis/index.ts: -------------------------------------------------------------------------------- 1 | import { RedisAPL } from "./redis-apl"; 2 | 3 | export { RedisAPL }; 4 | -------------------------------------------------------------------------------- /src/APL/saleor-cloud/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./saleor-cloud-apl"; 2 | export * from "./saleor-cloud-apl-errors"; 3 | -------------------------------------------------------------------------------- /src/APL/saleor-cloud/paginator.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { Paginator } from "./paginator"; 4 | 5 | const fetchMock = vi.fn(); 6 | 7 | describe("Paginator", () => { 8 | afterEach(() => { 9 | vi.restoreAllMocks(); 10 | }); 11 | 12 | it("Returns single page when there is no `next` property", async () => { 13 | fetchMock.mockResolvedValue({ 14 | status: 200, 15 | json: async () => ({ count: 1, next: null, previous: null, results: [{ ok: "yes" }] }), 16 | ok: true, 17 | }); 18 | const paginator = new Paginator("https://test.com", {}, fetchMock); 19 | const result = await paginator.fetchAll(); 20 | 21 | expect(fetchMock).toHaveBeenCalledOnce(); 22 | expect(result).toStrictEqual({ 23 | count: 1, 24 | next: null, 25 | previous: null, 26 | results: [{ ok: "yes" }], 27 | }); 28 | }); 29 | 30 | it("Returns all pages when there is `next` property", async () => { 31 | fetchMock 32 | .mockResolvedValueOnce({ 33 | status: 200, 34 | json: async () => ({ 35 | next: "https://test.com?page=2", 36 | previous: null, 37 | count: 2, 38 | results: [{ ok: "1" }], 39 | }), 40 | ok: true, 41 | }) 42 | .mockResolvedValueOnce({ 43 | status: 200, 44 | json: async () => ({ 45 | next: null, 46 | previous: "https://test.com?page=1", 47 | count: 2, 48 | results: [{ ok: "2" }], 49 | }), 50 | ok: true, 51 | }); 52 | const paginator = new Paginator("https://test.com", {}, fetchMock); 53 | const result = await paginator.fetchAll(); 54 | 55 | expect(fetchMock).toHaveBeenLastCalledWith("https://test.com?page=2", {}); 56 | expect(fetchMock).toHaveBeenCalledTimes(2); 57 | expect(result).toStrictEqual({ 58 | count: 2, 59 | next: null, 60 | previous: null, 61 | results: [{ ok: "1" }, { ok: "2" }], 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/APL/saleor-cloud/paginator.ts: -------------------------------------------------------------------------------- 1 | import { createAPLDebug } from "../apl-debug"; 2 | 3 | const debug = createAPLDebug("Paginator"); 4 | 5 | interface PaginationResponse { 6 | next: string | null; 7 | previous: string | null; 8 | results: ResultType[]; 9 | } 10 | 11 | export class Paginator { 12 | constructor( 13 | private readonly url: string, 14 | private readonly fetchOptions: RequestInit, 15 | private readonly fetchFn = fetch 16 | ) {} 17 | 18 | public async fetchAll() { 19 | debug("Fetching all pages for url", this.url); 20 | const response = await this.fetchFn(this.url, this.fetchOptions); 21 | debug("%0", response); 22 | 23 | const json = (await response.json()) as PaginationResponse; 24 | 25 | if (json.next) { 26 | const remainingPages = await this.fetchNext(json.next); 27 | const allResults = [...json.results, ...remainingPages.flatMap((page) => page.results)]; 28 | debug("Fetched all pages, total length: %d", allResults.length); 29 | 30 | return { 31 | next: null, 32 | previous: null, 33 | count: allResults.length, 34 | results: allResults, 35 | }; 36 | } 37 | 38 | debug("No more pages to fetch, returning first page"); 39 | return json; 40 | } 41 | 42 | private async fetchNext(nextUrl: string): Promise>> { 43 | debug("Fetching next page with url %s", nextUrl); 44 | const response = await this.fetchFn(nextUrl, this.fetchOptions); 45 | debug("%0", response); 46 | 47 | const json = (await response.json()) as PaginationResponse; 48 | 49 | if (json.next) { 50 | return [json, ...(await this.fetchNext(json.next))]; 51 | } 52 | return [json]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/APL/saleor-cloud/saleor-cloud-apl-errors.ts: -------------------------------------------------------------------------------- 1 | export class SaleorCloudAplError extends Error { 2 | constructor(public code: string, message: string) { 3 | super(message); 4 | this.name = "SaleorCloudAplError"; 5 | } 6 | } 7 | 8 | export const CloudAplError = { 9 | FAILED_TO_REACH_API: "FAILED_TO_REACH_API", 10 | RESPONSE_BODY_INVALID: "RESPONSE_BODY_INVALID", 11 | RESPONSE_NON_200: "RESPONSE_NON_200", 12 | ERROR_SAVING_DATA: "ERROR_SAVING_DATA", 13 | ERROR_DELETING_DATA: "ERROR_DELETING_DATA", 14 | }; 15 | -------------------------------------------------------------------------------- /src/APL/upstash/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./upstash-apl"; 2 | -------------------------------------------------------------------------------- /src/APL/vercel-kv/index.ts: -------------------------------------------------------------------------------- 1 | import { VercelKvApl } from "./vercel-kv-apl"; 2 | 3 | export { VercelKvApl }; 4 | -------------------------------------------------------------------------------- /src/APL/vercel-kv/vercel-kv-apl.test.ts: -------------------------------------------------------------------------------- 1 | import { kv, VercelKV } from "@vercel/kv"; 2 | import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; 3 | 4 | import { AuthData } from "../apl"; 5 | import { VercelKvApl } from "./vercel-kv-apl"; 6 | 7 | vi.mock("@vercel/kv", () => { 8 | /** 9 | * Client uses only hash methods 10 | */ 11 | const mockKv: Pick = { 12 | hget: vi.fn(), 13 | hset: vi.fn(), 14 | hdel: vi.fn(), 15 | hgetall: vi.fn(), 16 | }; 17 | 18 | return { kv: mockKv }; 19 | }); 20 | 21 | const getMockAuthData = (saleorApiUrl = "https://demo.saleor.io/graphql"): AuthData => ({ 22 | appId: "foobar", 23 | saleorApiUrl, 24 | token: "token", 25 | jwks: "{}", 26 | }); 27 | 28 | const APP_NAME_NAMESPACE = "test-app"; 29 | 30 | describe("VercelKvApl", () => { 31 | beforeEach(() => { 32 | vi.stubEnv("KV_URL", "https://url.vercel.io"); 33 | vi.stubEnv("KV_REST_API_URL", "https://url.vercel.io"); 34 | vi.stubEnv("KV_REST_API_TOKEN", "test-token"); 35 | vi.stubEnv("KV_REST_API_READ_ONLY_TOKEN", "test-read-token"); 36 | vi.stubEnv("KV_STORAGE_NAMESPACE", APP_NAME_NAMESPACE); 37 | }); 38 | 39 | it("Constructs", () => { 40 | expect(new VercelKvApl()).toBeDefined(); 41 | }); 42 | 43 | it("Fails if envs are missing", () => { 44 | vi.unstubAllEnvs(); 45 | 46 | expect(() => new VercelKvApl()).toThrow(); 47 | }); 48 | 49 | describe("get", () => { 50 | it("returns parsed auth data", async () => { 51 | (kv.hget as Mock).mockImplementationOnce(async () => getMockAuthData()); 52 | 53 | const apl = new VercelKvApl(); 54 | 55 | const authData = await apl.get("https://demo.saleor.io/graphql"); 56 | 57 | expect(authData).toEqual(getMockAuthData()); 58 | }); 59 | }); 60 | 61 | describe("set", () => { 62 | it("Sets auth data under a namespace provided in env", async () => { 63 | const apl = new VercelKvApl(); 64 | 65 | await apl.set(getMockAuthData()); 66 | 67 | expect(kv.hset).toHaveBeenCalledWith(APP_NAME_NAMESPACE, { 68 | "https://demo.saleor.io/graphql": getMockAuthData(), 69 | }); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/app-bridge/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { actions, NotificationPayload, RedirectPayload } from "./actions"; 4 | 5 | describe("actions.ts", () => { 6 | afterEach(() => { 7 | vi.restoreAllMocks(); 8 | }); 9 | 10 | describe("actions.Notification", () => { 11 | it("Constructs action with \"notification\" type, random id and payload", () => { 12 | const payload: NotificationPayload = { 13 | apiMessage: "test-api-message", 14 | status: "info", 15 | text: "test-text", 16 | title: "test-title", 17 | }; 18 | 19 | const action = actions.Notification(payload); 20 | 21 | expect(action.type).toBe("notification"); 22 | expect(action.payload.actionId).toEqual(expect.any(String)); 23 | expect(action.payload).toEqual(expect.objectContaining(payload)); 24 | }); 25 | }); 26 | 27 | describe("actions.Redirect", () => { 28 | it("Constructs action with \"redirect\" type, random id and payload", () => { 29 | const payload: RedirectPayload = { 30 | newContext: true, 31 | to: "/foo/bar", 32 | }; 33 | 34 | const action = actions.Redirect(payload); 35 | 36 | expect(action.type).toBe("redirect"); 37 | expect(action.payload.actionId).toEqual(expect.any(String)); 38 | expect(action.payload).toEqual(expect.objectContaining(payload)); 39 | }); 40 | }); 41 | 42 | it("Throws custom error if crypto is not available", () => { 43 | vi.stubGlobal("crypto", { 44 | ...globalThis.crypto, 45 | randomUUID: undefined, 46 | }); 47 | 48 | return expect(() => 49 | actions.Notification({ 50 | title: "Test", 51 | }), 52 | ).throws("Failed to generate action ID. Please ensure you are using https or localhost"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app-bridge/actions.ts: -------------------------------------------------------------------------------- 1 | import { AppPermission } from "../types"; 2 | import { Values } from "./helpers"; 3 | 4 | // Using constants over Enums, more info: https://fettblog.eu/tidy-typescript-avoid-enums/ 5 | export const ActionType = { 6 | /** 7 | * Ask Dashboard to redirect - either internal or external route 8 | */ 9 | redirect: "redirect", 10 | /** 11 | * Ask Dashboard to send a notification toast 12 | */ 13 | notification: "notification", 14 | /** 15 | * Ask Dashboard to update deep URL to preserve app route after refresh 16 | */ 17 | updateRouting: "updateRouting", 18 | /** 19 | * Inform Dashboard that AppBridge is ready 20 | */ 21 | notifyReady: "notifyReady", 22 | /** 23 | * Request one or more permissions from the Dashboard 24 | * 25 | * Available from 3.15 26 | */ 27 | requestPermission: "requestPermissions", 28 | } as const; 29 | 30 | export type ActionType = Values; 31 | 32 | type Action = { 33 | payload: Payload; 34 | type: Name; 35 | }; 36 | 37 | type ActionWithId = { 38 | payload: Payload & { actionId: string }; 39 | type: Name; 40 | }; 41 | 42 | function withActionId>( 43 | action: T, 44 | ): ActionWithId { 45 | try { 46 | const actionId = globalThis.crypto.randomUUID(); 47 | 48 | return { 49 | ...action, 50 | payload: { 51 | ...action.payload, 52 | actionId, 53 | }, 54 | }; 55 | } catch (e) { 56 | throw new Error("Failed to generate action ID. Please ensure you are using https or localhost"); 57 | } 58 | } 59 | 60 | export type RedirectPayload = { 61 | /** 62 | * Relative (inside Dashboard) or absolute URL path. 63 | */ 64 | to: string; 65 | newContext?: boolean; 66 | }; 67 | /** 68 | * Redirects Dashboard user. 69 | */ 70 | export type RedirectAction = ActionWithId<"redirect", RedirectPayload>; 71 | 72 | function createRedirectAction(payload: RedirectPayload): RedirectAction { 73 | return withActionId({ 74 | payload, 75 | type: "redirect", 76 | }); 77 | } 78 | 79 | export type NotificationPayload = { 80 | /** 81 | * Matching Dashboard's notification object. 82 | */ 83 | status?: "info" | "success" | "warning" | "error"; 84 | title?: string; 85 | text?: string; 86 | apiMessage?: string; 87 | }; 88 | 89 | export type NotificationAction = ActionWithId<"notification", NotificationPayload>; 90 | /** 91 | * Shows a notification using Dashboard's notification system. 92 | */ 93 | function createNotificationAction(payload: NotificationPayload): NotificationAction { 94 | return withActionId({ 95 | type: "notification", 96 | payload, 97 | }); 98 | } 99 | 100 | export type UpdateRoutingPayload = { 101 | newRoute: string; 102 | }; 103 | 104 | export type UpdateRouting = ActionWithId<"updateRouting", UpdateRoutingPayload>; 105 | 106 | function createUpdateRoutingAction(payload: UpdateRoutingPayload): UpdateRouting { 107 | return withActionId({ 108 | type: "updateRouting", 109 | payload, 110 | }); 111 | } 112 | 113 | export type NotifyReady = ActionWithId<"notifyReady", {}>; 114 | 115 | function createNotifyReadyAction(): NotifyReady { 116 | return withActionId({ 117 | type: "notifyReady", 118 | payload: {}, 119 | }); 120 | } 121 | 122 | export type RequestPermissions = ActionWithId< 123 | "requestPermissions", 124 | { 125 | permissions: AppPermission[]; 126 | redirectPath: string; 127 | } 128 | >; 129 | 130 | function createRequestPermissionsAction( 131 | permissions: AppPermission[], 132 | redirectPath: string, 133 | ): RequestPermissions { 134 | return withActionId({ 135 | type: "requestPermissions", 136 | payload: { 137 | permissions, 138 | redirectPath, 139 | }, 140 | }); 141 | } 142 | 143 | export type Actions = 144 | | RedirectAction 145 | | NotificationAction 146 | | UpdateRouting 147 | | NotifyReady 148 | | RequestPermissions; 149 | 150 | export const actions = { 151 | Redirect: createRedirectAction, 152 | Notification: createNotificationAction, 153 | UpdateRouting: createUpdateRoutingAction, 154 | NotifyReady: createNotifyReadyAction, 155 | RequestPermissions: createRequestPermissionsAction, 156 | }; 157 | -------------------------------------------------------------------------------- /src/app-bridge/app-bridge-provider.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, renderHook, waitFor } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | 5 | import { AppBridge } from "./app-bridge"; 6 | import { AppBridgeProvider, useAppBridge } from "./app-bridge-provider"; 7 | import { AppIframeParams } from "./app-iframe-params"; 8 | import { DashboardEventFactory } from "./events"; 9 | 10 | const origin = "http://example.com"; 11 | const domain = "saleor.domain.host"; 12 | const saleorApiUrl = "https://saleor.domain.host/graphql/"; 13 | 14 | Object.defineProperty(window.document, "referrer", { 15 | value: origin, 16 | writable: true, 17 | }); 18 | 19 | Object.defineProperty(window, "location", { 20 | value: { 21 | href: `${origin}?${AppIframeParams.DOMAIN}=${domain}&${AppIframeParams.APP_ID}=appid&${AppIframeParams.SALEOR_API_URL}=${saleorApiUrl}`, 22 | }, 23 | writable: true, 24 | }); 25 | 26 | describe("AppBridgeProvider", () => { 27 | it("Mounts provider in React DOM", () => { 28 | const { container } = render( 29 | 30 |
31 | , 32 | ); 33 | 34 | expect(container).toBeDefined(); 35 | }); 36 | 37 | it("Mounts provider in React DOM with provided AppBridge instance", () => { 38 | const { container } = render( 39 | 46 |
47 | , 48 | ); 49 | 50 | expect(container).toBeDefined(); 51 | }); 52 | }); 53 | 54 | describe("useAppBridge hook", () => { 55 | it("App is defined when wrapped in AppBridgeProvider", async () => { 56 | const { result } = renderHook(() => useAppBridge(), { 57 | wrapper: AppBridgeProvider, 58 | }); 59 | 60 | expect(result.current.appBridge).toBeDefined(); 61 | }); 62 | 63 | it("Throws if not wrapped inside AppBridgeProvider", () => { 64 | expect.assertions(2); 65 | 66 | let appBridgeResult: AppBridge | undefined; 67 | 68 | try { 69 | const { result } = renderHook(() => useAppBridge()); 70 | appBridgeResult = result.current.appBridge; 71 | } catch (e) { 72 | expect(e).toEqual(Error("useAppBridge used outside of AppBridgeProvider")); 73 | expect(appBridgeResult).toBeUndefined(); 74 | } 75 | }); 76 | 77 | it("Returned instance provided in Provider", () => { 78 | const appBridge = new AppBridge({ 79 | saleorApiUrl, 80 | }); 81 | 82 | const { result } = renderHook(() => useAppBridge(), { 83 | wrapper: (props: {}) => , 84 | }); 85 | 86 | expect(result.current.appBridge?.getState().saleorApiUrl).toBe(saleorApiUrl); 87 | }); 88 | 89 | it("Stores active state in React State", () => { 90 | const appBridge = new AppBridge({ 91 | saleorApiUrl, 92 | }); 93 | 94 | const renderCallback = vi.fn(); 95 | 96 | function TestComponent() { 97 | const { appBridgeState } = useAppBridge(); 98 | 99 | renderCallback(appBridgeState); 100 | 101 | return null; 102 | } 103 | 104 | render( 105 | 106 | 107 | , 108 | ); 109 | 110 | fireEvent( 111 | window, 112 | new MessageEvent("message", { 113 | data: DashboardEventFactory.createThemeChangeEvent("light"), 114 | origin, 115 | }), 116 | ); 117 | 118 | return waitFor(() => { 119 | expect(renderCallback).toHaveBeenCalledTimes(2); 120 | expect(renderCallback).toHaveBeenCalledWith({ 121 | id: "appid", 122 | path: "", 123 | ready: false, 124 | theme: "light", 125 | locale: "en", 126 | saleorApiUrl, 127 | }); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/app-bridge/app-bridge-provider.tsx: -------------------------------------------------------------------------------- 1 | import debugPkg from "debug"; 2 | import * as React from "react"; 3 | import { useCallback, useContext, useEffect, useMemo, useState } from "react"; 4 | 5 | import { AppBridge } from "./app-bridge"; 6 | import { AppBridgeState } from "./app-bridge-state"; 7 | 8 | const debug = debugPkg.debug("app-sdk:AppBridgeProvider"); 9 | 10 | interface AppBridgeContext { 11 | /** 12 | * App can be undefined, because it gets initialized in Browser only 13 | */ 14 | appBridge?: AppBridge; 15 | mounted: boolean; 16 | } 17 | 18 | type Props = { 19 | appBridgeInstance?: AppBridge; 20 | }; 21 | 22 | export const AppContext = React.createContext({ 23 | appBridge: undefined, 24 | mounted: false, 25 | }); 26 | 27 | export function AppBridgeProvider({ appBridgeInstance, ...props }: React.PropsWithChildren) { 28 | debug("Provider mounted"); 29 | const [appBridge, setAppBridge] = useState(appBridgeInstance); 30 | 31 | useEffect(() => { 32 | if (!appBridge) { 33 | debug("AppBridge not defined, will create new instance"); 34 | setAppBridge(appBridgeInstance ?? new AppBridge()); 35 | } else { 36 | debug("AppBridge provided in props, will use this one"); 37 | } 38 | }, []); 39 | 40 | const contextValue = useMemo( 41 | (): AppBridgeContext => ({ 42 | appBridge, 43 | mounted: true, 44 | }), 45 | [appBridge] 46 | ); 47 | 48 | return ; 49 | } 50 | 51 | export const useAppBridge = () => { 52 | const { appBridge, mounted } = useContext(AppContext); 53 | const [appBridgeState, setAppBridgeState] = useState(() => 54 | appBridge ? appBridge.getState() : null 55 | ); 56 | 57 | if (typeof window !== "undefined" && !mounted) { 58 | throw new Error("useAppBridge used outside of AppBridgeProvider"); 59 | } 60 | 61 | const updateState = useCallback(() => { 62 | if (appBridge?.getState()) { 63 | debug("Detected state change in AppBridge, will set new state"); 64 | setAppBridgeState(appBridge.getState()); 65 | } 66 | }, [appBridge]); 67 | 68 | useEffect(() => { 69 | let unsubscribes: Array = []; 70 | 71 | if (appBridge) { 72 | debug("Provider mounted, will set up listeners"); 73 | 74 | unsubscribes = [ 75 | appBridge.subscribe("handshake", updateState), 76 | appBridge.subscribe("theme", updateState), 77 | appBridge.subscribe("response", updateState), 78 | appBridge.subscribe("redirect", updateState), 79 | ]; 80 | } 81 | 82 | return () => { 83 | debug("Provider unmounted, will clean up listeners"); 84 | unsubscribes.forEach((unsubscribe) => unsubscribe()); 85 | }; 86 | }, [appBridge, updateState]); 87 | 88 | return { appBridge, appBridgeState }; 89 | }; 90 | -------------------------------------------------------------------------------- /src/app-bridge/app-bridge-state.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; 4 | 5 | describe("app-bridge-state.ts", () => { 6 | it("Creates with default state", () => { 7 | const instance = new AppBridgeStateContainer(); 8 | 9 | expect(instance.getState()).toEqual({ 10 | id: "", 11 | ready: false, 12 | path: "/", 13 | theme: "light", 14 | locale: "en", 15 | saleorApiUrl: "", 16 | }); 17 | }); 18 | 19 | it("Can update state", () => { 20 | const instance = new AppBridgeStateContainer(); 21 | 22 | const newState: Partial = { 23 | saleorApiUrl: "https://my-saleor-instance.cloud/graphql/", 24 | id: "foo-bar", 25 | path: "/", 26 | theme: "dark", 27 | locale: "pl", 28 | }; 29 | 30 | instance.setState(newState); 31 | 32 | expect(instance.getState()).toEqual(expect.objectContaining(newState)); 33 | }); 34 | 35 | it("Set \"en\" to be initial locale value", () => { 36 | expect(new AppBridgeStateContainer().getState().locale).toEqual("en"); 37 | }); 38 | 39 | it("Can be constructed with initial locale", () => { 40 | expect( 41 | new AppBridgeStateContainer({ 42 | initialLocale: "pl", 43 | }).getState().locale, 44 | ).toBe("pl"); 45 | }); 46 | 47 | it("Can be constructed with initial theme", () => { 48 | expect( 49 | new AppBridgeStateContainer({ 50 | initialTheme: "dark", 51 | }).getState().theme, 52 | ).toBe("dark"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app-bridge/app-bridge-state.ts: -------------------------------------------------------------------------------- 1 | import { LocaleCode } from "../locales"; 2 | import { AppPermission, Permission } from "../types"; 3 | import { ThemeType } from "./events"; 4 | 5 | export type AppBridgeState = { 6 | token?: string; 7 | id: string; 8 | ready: boolean; 9 | path: string; 10 | theme: ThemeType; 11 | locale: LocaleCode; 12 | saleorApiUrl: string; 13 | saleorVersion?: string; 14 | dashboardVersion?: string; 15 | user?: { 16 | /** 17 | * Original permissions of the user that is using the app. 18 | * *Not* the same permissions as the app itself. 19 | * 20 | * Can be used by app to check if user is authorized to perform 21 | * domain specific actions 22 | */ 23 | permissions: Permission[]; 24 | email: string; 25 | }; 26 | appPermissions?: AppPermission[]; 27 | }; 28 | 29 | type Options = { 30 | initialLocale?: LocaleCode; 31 | initialTheme?: ThemeType; 32 | }; 33 | 34 | export class AppBridgeStateContainer { 35 | private state: AppBridgeState = { 36 | id: "", 37 | saleorApiUrl: "", 38 | ready: false, 39 | path: "/", 40 | theme: "light", 41 | locale: "en", 42 | }; 43 | 44 | constructor(options: Options = {}) { 45 | this.state.locale = options.initialLocale ?? this.state.locale; 46 | this.state.theme = options.initialTheme ?? this.state.theme; 47 | } 48 | 49 | getState() { 50 | return this.state; 51 | } 52 | 53 | setState(newState: Partial) { 54 | this.state = { 55 | ...this.state, 56 | ...newState, 57 | }; 58 | 59 | return this.state; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app-bridge/app-iframe-params.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains keys of SearchParams added to iframe src 3 | */ 4 | export const AppIframeParams = { 5 | APP_ID: "id", 6 | THEME: "theme", 7 | DOMAIN: "domain", 8 | SALEOR_API_URL: "saleorApiUrl", 9 | LOCALE: "locale", 10 | }; 11 | -------------------------------------------------------------------------------- /src/app-bridge/constants.ts: -------------------------------------------------------------------------------- 1 | export const SSR = typeof window === "undefined"; 2 | -------------------------------------------------------------------------------- /src/app-bridge/events.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { DashboardEventFactory } from "./events"; 4 | 5 | describe("DashboardEventFactory", () => { 6 | it("Creates handshake event", () => { 7 | expect( 8 | DashboardEventFactory.createHandshakeEvent("mock-token", 1, { 9 | dashboard: "3.15.3", 10 | core: "3.15.1", 11 | }) 12 | ).toEqual({ 13 | payload: { 14 | token: "mock-token", 15 | version: 1, 16 | saleorVersion: "3.15.1", 17 | dashboardVersion: "3.15.3", 18 | }, 19 | type: "handshake", 20 | }); 21 | }); 22 | 23 | it("Creates redirect event", () => { 24 | expect(DashboardEventFactory.createRedirectEvent("/new-path")).toEqual({ 25 | payload: { 26 | path: "/new-path", 27 | }, 28 | type: "redirect", 29 | }); 30 | }); 31 | 32 | it("Creates dispatch response event", () => { 33 | expect(DashboardEventFactory.createDispatchResponseEvent("123", true)).toEqual({ 34 | payload: { 35 | actionId: "123", 36 | ok: true, 37 | }, 38 | type: "response", 39 | }); 40 | }); 41 | 42 | it("Creates theme change event", () => { 43 | expect(DashboardEventFactory.createThemeChangeEvent("light")).toEqual({ 44 | payload: { 45 | theme: "light", 46 | }, 47 | type: "theme", 48 | }); 49 | }); 50 | 51 | it("Creates locale change event", () => { 52 | expect(DashboardEventFactory.createLocaleChangedEvent("it")).toEqual({ 53 | payload: { 54 | locale: "it", 55 | }, 56 | type: "localeChanged", 57 | }); 58 | }); 59 | 60 | it("Creates token refresh event", () => { 61 | expect(DashboardEventFactory.createTokenRefreshEvent("TOKEN")).toEqual({ 62 | payload: { 63 | token: "TOKEN", 64 | }, 65 | type: "tokenRefresh", 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/app-bridge/events.ts: -------------------------------------------------------------------------------- 1 | import { LocaleCode } from "../locales"; 2 | import { Values } from "./helpers"; 3 | 4 | export type Version = 1; 5 | 6 | export const EventType = { 7 | handshake: "handshake", 8 | response: "response", 9 | redirect: "redirect", 10 | theme: "theme", 11 | localeChanged: "localeChanged", 12 | tokenRefresh: "tokenRefresh", 13 | } as const; 14 | 15 | export type EventType = Values; 16 | 17 | type Event = { 18 | payload: Payload; 19 | type: Name; 20 | }; 21 | 22 | export type HandshakeEvent = Event< 23 | "handshake", 24 | { 25 | token: string; 26 | version: Version; 27 | saleorVersion?: string; 28 | dashboardVersion?: string; 29 | } 30 | >; 31 | 32 | export type DispatchResponseEvent = Event< 33 | "response", 34 | { 35 | actionId: string; 36 | ok: boolean; 37 | } 38 | >; 39 | 40 | export type RedirectEvent = Event< 41 | "redirect", 42 | { 43 | path: string; 44 | } 45 | >; 46 | 47 | export type ThemeType = "light" | "dark"; 48 | export type ThemeEvent = Event< 49 | "theme", 50 | { 51 | theme: ThemeType; 52 | } 53 | >; 54 | 55 | export type LocaleChangedEvent = Event< 56 | "localeChanged", 57 | { 58 | locale: LocaleCode; 59 | } 60 | >; 61 | 62 | export type TokenRefreshEvent = Event< 63 | "tokenRefresh", 64 | { 65 | token: string; 66 | } 67 | >; 68 | 69 | export type Events = 70 | | HandshakeEvent 71 | | DispatchResponseEvent 72 | | RedirectEvent 73 | | ThemeEvent 74 | | LocaleChangedEvent 75 | | TokenRefreshEvent; 76 | 77 | export type PayloadOfEvent< 78 | TEventType extends EventType, 79 | TEvent extends Events = Events 80 | // @ts-ignore TODO - why this is not working with this tsconfig? Fixme 81 | > = TEvent extends Event ? TEvent["payload"] : never; 82 | 83 | export const DashboardEventFactory = { 84 | createThemeChangeEvent(theme: ThemeType): ThemeEvent { 85 | return { 86 | payload: { 87 | theme, 88 | }, 89 | type: "theme", 90 | }; 91 | }, 92 | createRedirectEvent(path: string): RedirectEvent { 93 | return { 94 | type: "redirect", 95 | payload: { 96 | path, 97 | }, 98 | }; 99 | }, 100 | createDispatchResponseEvent(actionId: string, ok: boolean): DispatchResponseEvent { 101 | return { 102 | type: "response", 103 | payload: { 104 | actionId, 105 | ok, 106 | }, 107 | }; 108 | }, 109 | createHandshakeEvent( 110 | token: string, 111 | // eslint-disable-next-line default-param-last 112 | version: Version = 1, 113 | saleorVersions?: { 114 | dashboard: string; 115 | core: string; 116 | } 117 | ): HandshakeEvent { 118 | return { 119 | type: "handshake", 120 | payload: { 121 | token, 122 | version, 123 | saleorVersion: saleorVersions?.core, 124 | dashboardVersion: saleorVersions?.dashboard, 125 | }, 126 | }; 127 | }, 128 | createLocaleChangedEvent(newLocale: LocaleCode): LocaleChangedEvent { 129 | return { 130 | type: "localeChanged", 131 | payload: { 132 | locale: newLocale, 133 | }, 134 | }; 135 | }, 136 | createTokenRefreshEvent(newToken: string): TokenRefreshEvent { 137 | return { 138 | type: "tokenRefresh", 139 | payload: { 140 | token: newToken, 141 | }, 142 | }; 143 | }, 144 | }; 145 | -------------------------------------------------------------------------------- /src/app-bridge/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { SALEOR_AUTHORIZATION_BEARER_HEADER } from "@/headers"; 4 | 5 | import { AppBridge } from "./app-bridge"; 6 | import { AppBridgeState } from "./app-bridge-state"; 7 | import { createAuthenticatedFetch } from "./fetch"; 8 | 9 | describe("createAuthenticatedFetch", () => { 10 | const mockedAppBridge: Pick = { 11 | getState(): AppBridgeState { 12 | return { 13 | token: "XXX_YYY", 14 | locale: "pl", 15 | path: "/", 16 | ready: true, 17 | theme: "light", 18 | saleorApiUrl: "https://master.staging.saleor.cloud/graphql/", 19 | id: "xyz1234", 20 | }; 21 | }, 22 | }; 23 | 24 | it("Decorates request headers with AppBridge headers", async () => { 25 | const spiedFetch = vi.spyOn(global, "fetch"); 26 | 27 | const fetchFn = createAuthenticatedFetch(mockedAppBridge); 28 | 29 | try { 30 | await fetchFn("/api/test"); 31 | } catch (e) { 32 | // ignore 33 | } 34 | 35 | const fetchCallArguments = spiedFetch.mock.lastCall; 36 | const fetchCallHeaders = fetchCallArguments![1]?.headers; 37 | 38 | expect((fetchCallHeaders as Headers).get(SALEOR_AUTHORIZATION_BEARER_HEADER)).toBe("XXX_YYY"); 39 | }); 40 | 41 | it("Extends existing fetch config", async () => { 42 | const spiedFetch = vi.spyOn(global, "fetch"); 43 | 44 | const fetchFn = createAuthenticatedFetch(mockedAppBridge); 45 | 46 | try { 47 | await fetchFn("/api/test", { 48 | headers: { 49 | foo: "bar", 50 | }, 51 | }); 52 | } catch (e) { 53 | // ignore 54 | } 55 | 56 | const fetchCallArguments = spiedFetch.mock.lastCall; 57 | const fetchCallHeaders = fetchCallArguments![1]?.headers; 58 | 59 | expect((fetchCallHeaders as Headers).get(SALEOR_AUTHORIZATION_BEARER_HEADER)).toBe("XXX_YYY"); 60 | expect((fetchCallHeaders as Headers).get("foo")).toBe("bar"); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/app-bridge/fetch.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@/headers"; 4 | 5 | import { AppBridge } from "./app-bridge"; 6 | import { useAppBridge } from "./app-bridge-provider"; 7 | 8 | type HasAppBridgeState = Pick; 9 | 10 | /** 11 | * Created decorated window.fetch with headers required by app-sdk Next api handlers utilities 12 | */ 13 | export const createAuthenticatedFetch = 14 | (appBridge: HasAppBridgeState, fetch = global.fetch): typeof global.fetch => 15 | (input, init) => { 16 | const { token, saleorApiUrl } = appBridge.getState(); 17 | 18 | const headers = new Headers(init?.headers); 19 | 20 | headers.set(SALEOR_AUTHORIZATION_BEARER_HEADER, token ?? ""); 21 | headers.set(SALEOR_API_URL_HEADER, saleorApiUrl ?? ""); 22 | 23 | const clonedInit: RequestInit = { 24 | ...(init ?? {}), 25 | headers, 26 | }; 27 | 28 | return fetch(input, clonedInit); 29 | }; 30 | 31 | /** 32 | * Hook working only in browser context. Ensure parent component is dynamic() and mounted in the browser. 33 | */ 34 | export const useAuthenticatedFetch = (fetch = window.fetch): typeof window.fetch => { 35 | const { appBridge } = useAppBridge(); 36 | 37 | if (!appBridge) { 38 | throw new Error("useAuthenticatedFetch can be used only in browser context"); 39 | } 40 | 41 | return useMemo(() => createAuthenticatedFetch(appBridge, fetch), [appBridge, fetch]); 42 | }; 43 | -------------------------------------------------------------------------------- /src/app-bridge/helpers.ts: -------------------------------------------------------------------------------- 1 | export type Values = T[keyof T]; 2 | -------------------------------------------------------------------------------- /src/app-bridge/index.ts: -------------------------------------------------------------------------------- 1 | import { AppBridge } from "./app-bridge"; 2 | 3 | export { AppBridge }; 4 | 5 | export * from "./actions"; 6 | export * from "./app-bridge-provider"; 7 | export * from "./app-iframe-params"; 8 | export * from "./events"; 9 | export * from "./fetch"; 10 | export * from "./types"; 11 | export * from "./use-dashboard-token"; 12 | export * from "./with-authorization"; 13 | -------------------------------------------------------------------------------- /src/app-bridge/next/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./route-propagator"; 2 | -------------------------------------------------------------------------------- /src/app-bridge/next/route-propagator.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Use .js extension to avoid broken builds with ESM 3 | */ 4 | import * as NextRouter from "next/router.js"; 5 | import { useEffect } from "react"; 6 | 7 | import { actions } from "../actions"; 8 | import { useAppBridge } from "../app-bridge-provider"; 9 | 10 | const { useRouter } = NextRouter; 11 | 12 | /** 13 | * Synchronizes app inner state (inside iframe) with dashboard routing, so app's route can be restored after refresh 14 | */ 15 | export const useRoutePropagator = () => { 16 | const { appBridge, appBridgeState } = useAppBridge(); 17 | const router = useRouter(); 18 | 19 | useEffect(() => { 20 | const appBridgeStateIsReady = appBridgeState?.ready ?? false; 21 | 22 | if (!appBridgeStateIsReady || !appBridge) { 23 | return; 24 | } 25 | 26 | router.events.on("routeChangeComplete", (url) => { 27 | appBridge 28 | ?.dispatch( 29 | actions.UpdateRouting({ 30 | newRoute: url, 31 | }) 32 | ) 33 | .catch(() => { 34 | console.error("Error dispatching action"); 35 | }); 36 | }); 37 | }, [appBridgeState, appBridge]); 38 | }; 39 | 40 | /** 41 | * Synchronizes app inner state (inside iframe) with dashboard routing, so app's route can be restored after refresh 42 | * 43 | * Component uses useRoutePropagator(), but it can consume context in the same component where provider was used (e.g. _app.tsx) 44 | */ 45 | export function RoutePropagator() { 46 | useRoutePropagator(); 47 | 48 | return null; 49 | } 50 | -------------------------------------------------------------------------------- /src/app-bridge/types.ts: -------------------------------------------------------------------------------- 1 | import { AppBridge } from "./app-bridge"; 2 | import { AppBridgeState } from "./app-bridge-state"; 3 | 4 | /** 5 | * @deprecated Use AppBridge instead 6 | */ 7 | export type App = AppBridge; 8 | export { AppBridgeState }; 9 | -------------------------------------------------------------------------------- /src/app-bridge/use-dashboard-token.ts: -------------------------------------------------------------------------------- 1 | import debugPkg from "debug"; 2 | import * as jose from "jose"; 3 | import { useMemo } from "react"; 4 | 5 | import { useAppBridge } from "./app-bridge-provider"; 6 | 7 | export interface DashboardTokenPayload extends jose.JWTPayload { 8 | app: string; 9 | } 10 | 11 | export interface DashboardTokenProps { 12 | isTokenValid: boolean; 13 | hasAppToken: boolean; 14 | tokenClaims: DashboardTokenPayload | null; 15 | } 16 | 17 | const debug = debugPkg.debug("app-sdk:AppBridge:useDashboardToken"); 18 | 19 | export const useDashboardToken = (): DashboardTokenProps => { 20 | const { appBridgeState } = useAppBridge(); 21 | 22 | const tokenClaims = useMemo(() => { 23 | try { 24 | if (appBridgeState?.token) { 25 | debug("Trying to decode JWT token from dashboard"); 26 | return jose.decodeJwt(appBridgeState?.token) as DashboardTokenPayload; 27 | } 28 | } catch (e) { 29 | debug("Failed decoding JWT token"); 30 | console.error(e); 31 | } 32 | return null; 33 | }, [appBridgeState?.token]); 34 | 35 | return { 36 | /** 37 | * TODO: Add tokenClaims.iss validation, when added to Saleor 38 | * @see: https://github.com/saleor/saleor/pull/10852 39 | */ 40 | isTokenValid: !!tokenClaims, 41 | tokenClaims, 42 | hasAppToken: Boolean(appBridgeState?.token), 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/app-bridge/with-authorization.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import * as React from "react"; 3 | import { PropsWithChildren, ReactNode } from "react"; 4 | 5 | import { isInIframe } from "@/util/is-in-iframe"; 6 | import { useIsMounted } from "@/util/use-is-mounted"; 7 | 8 | import { useDashboardToken } from "./use-dashboard-token"; 9 | 10 | function SimpleError({ children }: PropsWithChildren<{}>) { 11 | return ( 12 |
13 |

{children}

14 |
15 | ); 16 | } 17 | 18 | type Props = { 19 | unmounted?: ReactNode; 20 | notIframe?: ReactNode; 21 | noDashboardToken?: ReactNode; 22 | dashboardTokenInvalid?: ReactNode; 23 | }; 24 | 25 | const defaultProps: Props = { 26 | dashboardTokenInvalid: Dashboard token is invalid, 27 | noDashboardToken: Dashboard token doesn"t exist, 28 | notIframe: The view can only be displayed inside iframe., 29 | unmounted:

Loading

, 30 | }; 31 | 32 | type WithAuthorizationHOC

= React.FunctionComponent

& { 33 | getLayout?: (page: React.ReactElement) => React.ReactNode; 34 | }; 35 | 36 | /** 37 | * Most likely, views from your app will be only accessibly inside Dashboard iframe. 38 | * This HOC can be used to handle all checks, with default messages included. 39 | * Each error screen can be passed into HOC factory 40 | * 41 | * If screen can be accessible outside Dashboard - omit this HOC on this page 42 | * */ 43 | export const withAuthorization = 44 | (props: Props = defaultProps) => 45 | >( 46 | BaseComponent: React.FunctionComponent, 47 | ): WithAuthorizationHOC => { 48 | const { dashboardTokenInvalid, noDashboardToken, notIframe, unmounted } = { 49 | ...defaultProps, 50 | ...props, 51 | }; 52 | 53 | function AuthorizedPage(innerProps: BaseProps) { 54 | const mounted = useIsMounted(); 55 | const { isTokenValid, hasAppToken } = useDashboardToken(); 56 | 57 | if (!mounted) { 58 | return unmounted; 59 | } 60 | 61 | if (!isInIframe()) { 62 | return notIframe; 63 | } 64 | 65 | if (!hasAppToken) { 66 | return noDashboardToken; 67 | } 68 | 69 | if (!isTokenValid) { 70 | return dashboardTokenInvalid; 71 | } 72 | 73 | return ; 74 | } 75 | 76 | return AuthorizedPage as WithAuthorizationHOC; 77 | }; 78 | -------------------------------------------------------------------------------- /src/auth/fetch-remote-jwks.ts: -------------------------------------------------------------------------------- 1 | import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; 2 | import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; 3 | 4 | import { getJwksUrlFromSaleorApiUrl } from "@/auth/index"; 5 | 6 | import { getOtelTracer, OTEL_CORE_SERVICE_NAME } from "../open-telemetry"; 7 | 8 | export const fetchRemoteJwks = async (saleorApiUrl: string) => { 9 | const tracer = getOtelTracer(); 10 | 11 | return tracer.startActiveSpan( 12 | "fetchRemoteJwks", 13 | { 14 | kind: SpanKind.CLIENT, 15 | attributes: { saleorApiUrl, [SemanticAttributes.PEER_SERVICE]: OTEL_CORE_SERVICE_NAME }, 16 | }, 17 | async (span) => { 18 | try { 19 | const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl)); 20 | 21 | const jwksText = await jwksResponse.text(); 22 | 23 | span.setStatus({ code: SpanStatusCode.OK }); 24 | 25 | return jwksText; 26 | } catch (err) { 27 | span.setStatus({ 28 | code: SpanStatusCode.ERROR, 29 | }); 30 | 31 | throw err; 32 | } finally { 33 | span.end(); 34 | } 35 | }, 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/auth/has-permissions-in-jwt-token.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "../debug"; 2 | import { Permission } from "../types"; 3 | import { DashboardTokenPayload } from "./verify-jwt"; 4 | 5 | const debug = createDebug("checkJwtPermissions"); 6 | 7 | /** 8 | * Takes decoded JWT token that Dashboard provides via AppBridge. 9 | * Compare permissions against required in parameter 10 | */ 11 | export const hasPermissionsInJwtToken = ( 12 | tokenData?: Pick, 13 | permissionsToCheckAgainst?: Permission[], 14 | ) => { 15 | debug(`Permissions required ${permissionsToCheckAgainst}`); 16 | 17 | if (!permissionsToCheckAgainst?.length) { 18 | debug("No permissions specified, check passed"); 19 | return true; 20 | } 21 | 22 | const userPermissions = tokenData?.user_permissions || undefined; 23 | 24 | if (!userPermissions?.length) { 25 | debug("User has no permissions assigned. Rejected"); 26 | return false; 27 | } 28 | 29 | const arePermissionsSatisfied = permissionsToCheckAgainst.every((permission) => 30 | userPermissions.includes(permission), 31 | ); 32 | 33 | if (!arePermissionsSatisfied) { 34 | debug("Permissions check not passed"); 35 | return false; 36 | } 37 | 38 | debug("Permissions check successful"); 39 | return true; 40 | }; 41 | -------------------------------------------------------------------------------- /src/auth/has-permissions.in-jwt-token.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token"; 4 | import { DashboardTokenPayload } from "./verify-jwt"; 5 | 6 | describe("hasPermissionsInJwtToken", () => { 7 | it("Pass if no required permissions, and user has none", async () => { 8 | const tokenData: Pick = { 9 | user_permissions: [], 10 | }; 11 | await expect(hasPermissionsInJwtToken(tokenData)).toBeTruthy(); 12 | }); 13 | 14 | it("Pass if no required permissions", async () => { 15 | const tokenData: Pick = { 16 | user_permissions: ["MANAGE_ORDERS"], 17 | }; 18 | await expect(hasPermissionsInJwtToken(tokenData)).toBeTruthy(); 19 | }); 20 | 21 | it("Pass if user has assigned required permissions", async () => { 22 | const tokenData: Pick = { 23 | user_permissions: ["MANAGE_ORDERS", "MANAGE_CHECKOUTS", "HANDLE_TAXES"], 24 | }; 25 | await expect( 26 | hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]), 27 | ).toBeTruthy(); 28 | }); 29 | 30 | it("Reject if user is missing any of required permissions", async () => { 31 | const tokenData: Pick = { 32 | user_permissions: ["MANAGE_ORDERS", "HANDLE_TAXES"], 33 | }; 34 | await expect( 35 | hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]), 36 | ).toBeFalsy(); 37 | }); 38 | 39 | it("Reject if user is missing permission data", async () => { 40 | const tokenData: Pick = { 41 | user_permissions: [], 42 | }; 43 | await expect( 44 | hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]), 45 | ).toBeFalsy(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { verifyJWT } from "./verify-jwt"; 2 | export { getJwksUrlFromSaleorApiUrl, verifySignatureWithJwks } from "./verify-signature"; 3 | -------------------------------------------------------------------------------- /src/auth/verify-jwt.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { verifyJWT } from "./verify-jwt"; 4 | 5 | /** 6 | * exp field points to Nov 24, 2022 7 | */ 8 | const validToken = 9 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; 10 | 11 | const validApiUrl = "https://demo.eu.saleor.cloud/graphql/"; 12 | 13 | const validAppId = "QXBwOjI3NQ=="; 14 | 15 | const mockedTodayDateBeforeTokenExp = new Date(2022, 10, 20); 16 | const mockedTodayDateAfterTokenExp = new Date(2022, 10, 26); 17 | 18 | describe("verifyJWT", () => { 19 | beforeEach(() => { 20 | vi.useFakeTimers(); 21 | vi.setSystemTime(mockedTodayDateBeforeTokenExp); 22 | 23 | vi.mock("jose", async () => { 24 | const original = await vi.importActual("jose"); 25 | return { 26 | // @ts-ignore 27 | ...original, 28 | createRemoteJWKSet: vi.fn().mockImplementation(() => ""), 29 | jwtVerify: vi.fn().mockImplementation(() => ""), 30 | }; 31 | }); 32 | }); 33 | 34 | afterEach(() => { 35 | vi.restoreAllMocks(); 36 | vi.useRealTimers(); 37 | }); 38 | 39 | it("Process valid request", async () => { 40 | await verifyJWT({ appId: validAppId, saleorApiUrl: validApiUrl, token: validToken }); 41 | }); 42 | 43 | it("Throw error on decode issue", async () => { 44 | await expect( 45 | verifyJWT({ appId: validAppId, saleorApiUrl: validApiUrl, token: "wrong_token" }), 46 | ).rejects.toThrow("JWT verification failed: Could not decode authorization token."); 47 | }); 48 | 49 | it("Throw error on app ID missmatch", async () => { 50 | await expect( 51 | verifyJWT({ appId: "wrong_id", saleorApiUrl: validApiUrl, token: validToken }), 52 | ).rejects.toThrow("JWT verification failed: Token's app property is different than app ID."); 53 | }); 54 | 55 | it("Throw error on user missing the permissions", async () => { 56 | await expect( 57 | verifyJWT({ 58 | appId: validAppId, 59 | saleorApiUrl: validApiUrl, 60 | token: validToken, 61 | requiredPermissions: ["HANDLE_TAXES"], 62 | }), 63 | ).rejects.toThrow("JWT verification failed: Token's permissions are not sufficient."); 64 | }); 65 | 66 | it("Throws if today date is newer than token expiration", async () => { 67 | vi.setSystemTime(mockedTodayDateAfterTokenExp); 68 | 69 | await expect( 70 | verifyJWT({ 71 | appId: validAppId, 72 | saleorApiUrl: validApiUrl, 73 | token: validToken, 74 | }), 75 | ).rejects.toThrow("JWT verification failed: Token is expired"); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/auth/verify-jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | 3 | import { getJwksUrlFromSaleorApiUrl } from "@/auth/index"; 4 | 5 | import { createDebug } from "../debug"; 6 | import { Permission } from "../types"; 7 | import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token"; 8 | import { verifyTokenExpiration } from "./verify-token-expiration"; 9 | 10 | const debug = createDebug("verify-jwt"); 11 | 12 | export interface DashboardTokenPayload extends jose.JWTPayload { 13 | app: string; 14 | user_permissions: Permission[]; 15 | } 16 | 17 | export interface verifyJWTArguments { 18 | appId: string; 19 | saleorApiUrl: string; 20 | token: string; 21 | requiredPermissions?: Permission[]; 22 | } 23 | 24 | export const verifyJWT = async ({ 25 | saleorApiUrl, 26 | token, 27 | appId, 28 | requiredPermissions, 29 | }: verifyJWTArguments) => { 30 | let tokenClaims: DashboardTokenPayload; 31 | const ERROR_MESSAGE = "JWT verification failed:"; 32 | 33 | try { 34 | tokenClaims = jose.decodeJwt(token as string) as DashboardTokenPayload; 35 | debug("Token Claims decoded from jwt"); 36 | } catch (e) { 37 | debug("Token Claims could not be decoded from JWT, will respond with Bad Request"); 38 | throw new Error(`${ERROR_MESSAGE} Could not decode authorization token.`); 39 | } 40 | 41 | try { 42 | verifyTokenExpiration(tokenClaims); 43 | } catch (e) { 44 | throw new Error(`${ERROR_MESSAGE} ${(e as Error).message}`); 45 | } 46 | 47 | if (tokenClaims.app !== appId) { 48 | debug( 49 | "Resolved App ID value from token to be different than in request, will respond with Bad Request", 50 | ); 51 | 52 | throw new Error(`${ERROR_MESSAGE} Token's app property is different than app ID.`); 53 | } 54 | 55 | if (!hasPermissionsInJwtToken(tokenClaims, requiredPermissions)) { 56 | debug("Token did not meet requirements for permissions: %s", requiredPermissions); 57 | throw new Error(`${ERROR_MESSAGE} Token's permissions are not sufficient.`); 58 | } 59 | 60 | try { 61 | debug("Trying to create JWKS"); 62 | 63 | const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrlFromSaleorApiUrl(saleorApiUrl))); 64 | debug("Trying to compare JWKS with token"); 65 | await jose.jwtVerify(token, JWKS); 66 | } catch (e) { 67 | debug("Failure: %s", e); 68 | debug("Will return with Bad Request"); 69 | 70 | console.error(e); 71 | 72 | throw new Error(`${ERROR_MESSAGE} JWT signature verification failed.`); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/auth/verify-signature.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { verifySignatureWithJwks } from "@/auth/verify-signature"; 4 | 5 | /** 6 | * Actual signature copied from testing webhook payload. 7 | */ 8 | const testSignature = 9 | "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il0sImlzcyI6Imh0dHBzOi8vaGFja2F0aG9uLXNoaXBwaW5nLmV1LnNhbGVvci5jbG91ZC9ncmFwaHFsLyIsImtpZCI6InlLdzlYeUVmOHgyYnd5SkdCWVlTckhzeXpHaHhKaktLdlpkb2tOZTlGSWMiLCJ0eXAiOiJKV1QifQ..ShPbfvYc_A5Aq3hiT6sisDclKikDkhxOvGXT2ZWgdsGRjZpg9ukiHRZym0kbfMfDqU5C3Pfo6n7am0ExwbnFWBfOil3pfe3uJOcOn_UGRj76Fy-59TB0JdS_WuTgNQcYM8Yjvlq2sNK4jdAfJVRTTx8FVgEpFrHBKmcMPfD7zuDozswIDMZOkklYqBcyQ76DJYIRVhl3QsktYPPrxDoqf-GJ--e9FuNqtqNDksP1weiDSraqXCF4-Ie7UWZsMIFxkPF8jdKjF_s1UmNS8Xel8soFQQ9L6Gps-NEv7xcHicGt5lgohH4mqhz1YIxCR7v_NTQgWImu_GQ6ELBiBSIZ2Q"; 10 | 11 | const rawContent = "{\"__typename\": \"OrderCreated\"}"; 12 | 13 | const jwks = 14 | "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"uDhbbpspufsQiqHsmC4kvmFQ5l2mGZsGcWhKVSQKQubSdXMedPpLnPD3Z3DsY76DILTm6WfOtSp5rr4KzF5wjurlOEhuFsB1HUfK9ZZB2nEDCQbweoIv3SOdclaNB__pYvQ0nmQHwsAeqH1QUuFUIvOL3t31rhjvzX6wvS49fGNb7rDlqQjufCvaX_n-ADJTgEAg6y1Mzn5NhgoTV1KTBeviyZqCdwvD6bk1ghN2XXWpNcARTzu3WHrmzIzkTwQeIMG8efwIddjfCaMGiOzAfzdQlqHlHPL1Xb5kV9AVX3kiSy-9shaQY23HdWwwiodrb4k2w34Z9ZZN-MHp8i6JdQ\", \"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"yKw9XyEf8x2bwyJGBYYSrHsyzGhxJjKKvZdokNe9FIc\"}]}"; 15 | 16 | describe("verifySignatureWithJwks", () => { 17 | it("Returns empty promise if signature is valid", () => 18 | expect(verifySignatureWithJwks(jwks, testSignature, rawContent)).resolves.not.toThrow()); 19 | 20 | it("Throws if signature is invalid", () => 21 | expect( 22 | verifySignatureWithJwks(jwks, testSignature, "{\"forged\": \"payload\"}"), 23 | ).rejects.toThrow()); 24 | }); 25 | -------------------------------------------------------------------------------- /src/auth/verify-signature.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | 3 | import { createDebug } from "../debug"; 4 | 5 | const debug = createDebug("verify-signature"); 6 | 7 | /** 8 | * Verify the Webhook payload signature from provided JWKS string. 9 | * JWKS can be cached to avoid unnecessary calls. 10 | */ 11 | export const verifySignatureWithJwks = async (jwks: string, signature: string, rawBody: string) => { 12 | const [header, , jwsSignature] = signature.split("."); 13 | const jws: jose.FlattenedJWSInput = { 14 | protected: header, 15 | payload: rawBody, 16 | signature: jwsSignature, 17 | }; 18 | 19 | let localJwks: jose.FlattenedVerifyGetKey; 20 | 21 | try { 22 | const parsedJWKS = JSON.parse(jwks); 23 | 24 | localJwks = jose.createLocalJWKSet(parsedJWKS) as jose.FlattenedVerifyGetKey; 25 | } catch { 26 | debug("Could not create local JWKSSet from given data: %s", jwks); 27 | 28 | throw new Error("JWKS verification failed - could not parse given JWKS"); 29 | } 30 | 31 | try { 32 | await jose.flattenedVerify(jws, localJwks); 33 | debug("JWKS verified"); 34 | } catch { 35 | debug("JWKS verification failed"); 36 | throw new Error("JWKS verification failed"); 37 | } 38 | }; 39 | 40 | export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string => 41 | `${new URL(saleorApiUrl).origin}/.well-known/jwks.json`; 42 | -------------------------------------------------------------------------------- /src/auth/verify-token-expiration.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { DashboardTokenPayload } from "./verify-jwt"; 4 | import { verifyTokenExpiration } from "./verify-token-expiration"; 5 | 6 | describe("verifyTokenExpiration", () => { 7 | const mockTodayDate = new Date(2020, 2, 1, 9, 0, 0); 8 | 9 | beforeAll(() => { 10 | vi.useFakeTimers(); 11 | }); 12 | 13 | beforeEach(() => { 14 | vi.setSystemTime(mockTodayDate); 15 | }); 16 | 17 | afterAll(() => { 18 | vi.useRealTimers(); 19 | }); 20 | 21 | it("Passes if exp field in token is in the future from \"now\"", () => { 22 | const tokenDate = new Date(2020, 2, 1, 12, 0, 0); 23 | 24 | expect(() => 25 | verifyTokenExpiration({ 26 | /** 27 | * Must be seconds 28 | */ 29 | exp: tokenDate.valueOf() / 1000, 30 | } as DashboardTokenPayload), 31 | ).not.toThrow(); 32 | }); 33 | 34 | it("Throws if exp field is missing", () => { 35 | expect(() => verifyTokenExpiration({} as DashboardTokenPayload)).toThrow(); 36 | }); 37 | 38 | it("Throws if exp field is older than today", () => { 39 | const tokenDate = new Date(2020, 2, 1, 4, 0, 0); 40 | 41 | expect(() => 42 | verifyTokenExpiration({ 43 | /** 44 | * Must be seconds 45 | */ 46 | exp: tokenDate.valueOf() / 1000, 47 | } as DashboardTokenPayload), 48 | ).toThrow(); 49 | }); 50 | 51 | it("Throws if exp field is the same as today", () => { 52 | expect(() => 53 | verifyTokenExpiration({ 54 | /** 55 | * Must be seconds 56 | */ 57 | exp: mockTodayDate.valueOf() / 1000, 58 | } as DashboardTokenPayload), 59 | ).toThrow(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/auth/verify-token-expiration.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "../debug"; 2 | import { DashboardTokenPayload } from "./verify-jwt"; 3 | 4 | const debug = createDebug("verify-token-expiration"); 5 | 6 | /** 7 | * Takes user token that Dashboard provides via AppBridge (decoded). 8 | * Checks token expiration and throws if expired 9 | */ 10 | export const verifyTokenExpiration = (token: DashboardTokenPayload) => { 11 | const tokenExpiration = token.exp; 12 | const now = new Date(); 13 | const nowTimestamp = now.valueOf(); 14 | 15 | if (!tokenExpiration) { 16 | throw new Error("Missing \"exp\" field in token"); 17 | } 18 | 19 | /** 20 | * Timestamp in token are in seconds, but timestamp from Date is in miliseconds 21 | */ 22 | const tokenMsTimestamp = tokenExpiration * 1000; 23 | 24 | debug( 25 | "Comparing to days date: %s and token expiration date: %s", 26 | now.toLocaleString(), 27 | new Date(tokenMsTimestamp).toLocaleString(), 28 | ); 29 | 30 | if (tokenMsTimestamp <= nowTimestamp) { 31 | throw new Error("Token is expired"); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import debugPkg from "debug"; 2 | 3 | export const createDebug = (namespace: string) => debugPkg.debug(`app-sdk:${namespace}`); 4 | -------------------------------------------------------------------------------- /src/get-app-id.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "./debug"; 2 | 3 | const debug = createDebug("getAppId"); 4 | 5 | type GetIdResponseType = { 6 | data?: { 7 | app?: { 8 | id: string; 9 | }; 10 | }; 11 | }; 12 | 13 | export interface GetAppIdProperties { 14 | saleorApiUrl: string; 15 | token: string; 16 | } 17 | 18 | export const getAppId = async ({ 19 | saleorApiUrl, 20 | token, 21 | }: GetAppIdProperties): Promise => { 22 | try { 23 | const response = await fetch(saleorApiUrl, { 24 | method: "POST", 25 | headers: { 26 | "Content-Type": "application/json", 27 | Authorization: `Bearer ${token}`, 28 | }, 29 | body: JSON.stringify({ 30 | query: ` 31 | { 32 | app{ 33 | id 34 | } 35 | } 36 | `, 37 | }), 38 | }); 39 | if (response.status !== 200) { 40 | debug(`Could not get the app ID: Saleor API has response code ${response.status}`); 41 | return undefined; 42 | } 43 | const body = (await response.json()) as GetIdResponseType; 44 | const appId = body.data?.app?.id; 45 | return appId; 46 | } catch (e) { 47 | debug("Could not get the app ID: %O", e); 48 | return undefined; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/gql-ast-to-string.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, print } from "graphql"; 2 | 3 | export const gqlAstToString = (ast: ASTNode) => 4 | print(ast) // convert AST to string 5 | .replaceAll(/\n*/g, "") // remove new lines 6 | .replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces 7 | .trim(); // remove whitespace from beginning and end 8 | -------------------------------------------------------------------------------- /src/handlers/actions/manifest-action-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { SALEOR_SCHEMA_VERSION_HEADER } from "@/headers"; 4 | import { MockAdapter } from "@/test-utils/mock-adapter"; 5 | import { AppManifest } from "@/types"; 6 | 7 | import { ManifestActionHandler } from "./manifest-action-handler"; 8 | 9 | describe("ManifestActionHandler", () => { 10 | const mockManifest: AppManifest = { 11 | id: "test-app", 12 | name: "Test Application", 13 | version: "1.0.0", 14 | appUrl: "http://example.com", 15 | permissions: [], 16 | tokenTargetUrl: "http://example.com/token", 17 | }; 18 | 19 | let adapter: MockAdapter; 20 | 21 | beforeEach(() => { 22 | adapter = new MockAdapter({ 23 | mockHeaders: { 24 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 25 | }, 26 | baseUrl: "http://example.com", 27 | }); 28 | adapter.method = "GET"; 29 | }); 30 | 31 | it("should call manifest factory and return 200 status when it resolves", async () => { 32 | const handler = new ManifestActionHandler(adapter); 33 | const manifestFactory = vi.fn().mockResolvedValue(mockManifest); 34 | 35 | const result = await handler.handleAction({ manifestFactory }); 36 | 37 | expect(result.status).toBe(200); 38 | expect(result.body).toEqual(mockManifest); 39 | expect(manifestFactory).toHaveBeenCalledWith({ 40 | appBaseUrl: "http://example.com", 41 | request: {}, 42 | schemaVersion: [3, 20], 43 | }); 44 | }); 45 | 46 | it("should call manifest factory and return 500 when it throws an error", async () => { 47 | const handler = new ManifestActionHandler(adapter); 48 | const manifestFactory = vi.fn().mockRejectedValue(new Error("Test error")); 49 | 50 | const result = await handler.handleAction({ manifestFactory }); 51 | 52 | expect(result.status).toBe(500); 53 | expect(result.body).toBe("Error resolving manifest file."); 54 | }); 55 | 56 | it("should return 405 when not called using HTTP GET method", async () => { 57 | adapter.method = "POST"; 58 | const handler = new ManifestActionHandler(adapter); 59 | 60 | const manifestFactory = vi.fn().mockResolvedValue(mockManifest); 61 | 62 | const result = await handler.handleAction({ manifestFactory }); 63 | 64 | expect(result.status).toBe(405); 65 | expect(result.body).toBe("Method not allowed"); 66 | expect(manifestFactory).not.toHaveBeenCalled(); 67 | }); 68 | 69 | /** 70 | * api/manifest endpoint is GET and header should be optional. It can be used to install the app eventually, 71 | * but also to preview the manifest from the plain GET request 72 | */ 73 | it("should NOT return 400 when receives null schema version header from unsupported legacy Saleor version", async () => { 74 | adapter.getHeader = vi.fn().mockReturnValue(null); 75 | const handler = new ManifestActionHandler(adapter); 76 | 77 | const manifestFactory = vi.fn().mockResolvedValue(mockManifest); 78 | 79 | const result = await handler.handleAction({ manifestFactory }); 80 | 81 | expect(result.status).toBe(200); 82 | expect(manifestFactory).toHaveBeenCalled(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/handlers/actions/manifest-action-handler.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "@/debug"; 2 | import { AppManifest, SaleorSchemaVersion } from "@/types"; 3 | import { parseSchemaVersion } from "@/util/schema-version"; 4 | 5 | import { 6 | ActionHandlerInterface, 7 | ActionHandlerResult, 8 | PlatformAdapterInterface, 9 | } from "../shared/generic-adapter-use-case-types"; 10 | import { SaleorRequestProcessor } from "../shared/saleor-request-processor"; 11 | 12 | const debug = createDebug("create-manifest-handler"); 13 | 14 | export type CreateManifestHandlerOptions = { 15 | manifestFactory(context: { 16 | appBaseUrl: string; 17 | request: T; 18 | /** 19 | * Schema version is optional. During installation, Saleor will send it, 20 | * so manifest can be generated according to the version. But it may 21 | * be also requested from plain GET from the browser, so it may not be available 22 | */ 23 | schemaVersion?: SaleorSchemaVersion; 24 | }): AppManifest | Promise; 25 | }; 26 | 27 | export class ManifestActionHandler implements ActionHandlerInterface { 28 | constructor(private adapter: PlatformAdapterInterface) {} 29 | 30 | private requestProcessor = new SaleorRequestProcessor(this.adapter); 31 | 32 | async handleAction(options: CreateManifestHandlerOptions): Promise { 33 | const { schemaVersion } = this.requestProcessor.getSaleorHeaders(); 34 | const parsedSchemaVersion = parseSchemaVersion(schemaVersion) ?? undefined; 35 | const baseURL = this.adapter.getBaseUrl(); 36 | 37 | debug("Received request with schema version \"%s\" and base URL \"%s\"", schemaVersion, baseURL); 38 | 39 | const invalidMethodResponse = this.requestProcessor.withMethod(["GET"]); 40 | 41 | if (invalidMethodResponse) { 42 | return invalidMethodResponse; 43 | } 44 | 45 | try { 46 | const manifest = await options.manifestFactory({ 47 | appBaseUrl: baseURL, 48 | request: this.adapter.request, 49 | schemaVersion: parsedSchemaVersion, 50 | }); 51 | 52 | debug("Executed manifest file"); 53 | 54 | return { 55 | status: 200, 56 | bodyType: "json", 57 | body: manifest, 58 | }; 59 | } catch (e) { 60 | debug("Error while resolving manifest: %O", e); 61 | 62 | return { 63 | status: 500, 64 | bodyType: "string", 65 | body: "Error resolving manifest file.", 66 | }; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/create-app-register-handler.ts: -------------------------------------------------------------------------------- 1 | import { RegisterActionHandler } from "@/handlers/actions/register-action-handler"; 2 | import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; 3 | 4 | import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; 5 | 6 | export type CreateAppRegisterHandlerOptions = 7 | GenericCreateAppRegisterHandlerOptions; 8 | 9 | /** 10 | * Returns API route handler for AWS Lambda HTTP triggered events 11 | * (created by Amazon API Gateway, Lambda Function URL) 12 | * that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2 13 | * 14 | * Handler is for register endpoint that is called by Saleor when installing the app 15 | * 16 | * It verifies the request and stores `app_token` from Saleor 17 | * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) 18 | * 19 | * **Recommended path**: `/api/register` 20 | * (configured in manifest handler) 21 | * 22 | * To learn more check Saleor docs 23 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} 24 | * @see {@link https://www.npmjs.com/package/@types/aws-lambda} 25 | * */ 26 | export const createAppRegisterHandler = 27 | (config: CreateAppRegisterHandlerOptions): AWSLambdaHandler => 28 | async (event, context) => { 29 | const adapter = new AwsLambdaAdapter(event, context); 30 | const useCase = new RegisterActionHandler(adapter); 31 | const result = await useCase.handleAction(config); 32 | return adapter.send(result); 33 | }; 34 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/create-manifest-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { SALEOR_SCHEMA_VERSION_HEADER } from "@/headers"; 4 | 5 | import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; 6 | import { createLambdaEvent, mockLambdaContext } from "./test-utils"; 7 | 8 | describe("AWS Lambda createManifestHandler", () => { 9 | it("Creates a handler that responds with manifest, includes a request and baseUrl in factory method", async () => { 10 | // Note: This event uses $default stage which means it's not included in the URL 11 | // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html 12 | // also see platform-adapter 13 | const event = createLambdaEvent({ 14 | method: "GET", 15 | path: "/manifest", 16 | headers: { 17 | "content-type": "application/json", 18 | host: "some-app-host.cloud", 19 | "x-forwarded-proto": "https", 20 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 21 | }, 22 | }); 23 | const expectedBaseUrl = "https://some-app-host.cloud"; 24 | 25 | const mockManifestFactory = vi 26 | .fn() 27 | .mockImplementation(({ appBaseUrl }) => ({ 28 | name: "Test app", 29 | tokenTargetUrl: `${appBaseUrl}/api/register`, 30 | appUrl: appBaseUrl, 31 | permissions: [], 32 | id: "app-id", 33 | version: "1", 34 | })); 35 | 36 | const handler = createManifestHandler({ 37 | manifestFactory: mockManifestFactory, 38 | }); 39 | 40 | const response = await handler(event, mockLambdaContext); 41 | 42 | expect(mockManifestFactory).toHaveBeenCalledWith( 43 | expect.objectContaining({ 44 | appBaseUrl: expectedBaseUrl, 45 | request: event, 46 | schemaVersion: [3, 20], 47 | }), 48 | ); 49 | expect(response.statusCode).toBe(200); 50 | expect(JSON.parse(response.body!)).toStrictEqual({ 51 | appUrl: expectedBaseUrl, 52 | id: "app-id", 53 | name: "Test app", 54 | permissions: [], 55 | tokenTargetUrl: `${expectedBaseUrl}/api/register`, 56 | version: "1", 57 | }); 58 | }); 59 | 60 | it("Works with event that has AWS Lambda stage", async () => { 61 | // Note: AWS lambda uses stages which are passed in lambda request context 62 | // Contexts are appended to the lambda base URL, like so: / 63 | // In this case we're simulating test stage, which results in /test 64 | // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html 65 | const event = createLambdaEvent({ 66 | method: "GET", 67 | path: "/manifest", 68 | headers: { 69 | "content-type": "application/json", 70 | host: "some-app-host.cloud", 71 | "x-forwarded-proto": "https", 72 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 73 | }, 74 | requestContext: { 75 | stage: "test", 76 | }, 77 | }); 78 | const expectedBaseUrl = "https://some-app-host.cloud/test"; 79 | 80 | const mockManifestFactory = vi 81 | .fn() 82 | .mockImplementation(({ appBaseUrl }) => ({ 83 | name: "Test app", 84 | tokenTargetUrl: `${appBaseUrl}/api/register`, 85 | appUrl: appBaseUrl, 86 | permissions: [], 87 | id: "app-id", 88 | version: "1", 89 | })); 90 | 91 | const handler = createManifestHandler({ 92 | manifestFactory: mockManifestFactory, 93 | }); 94 | 95 | const response = await handler(event, mockLambdaContext); 96 | 97 | expect(mockManifestFactory).toHaveBeenCalledWith( 98 | expect.objectContaining({ 99 | appBaseUrl: expectedBaseUrl, 100 | request: event, 101 | schemaVersion: [3, 20], 102 | }), 103 | ); 104 | expect(response.statusCode).toBe(200); 105 | expect(JSON.parse(response.body!)).toStrictEqual({ 106 | appUrl: expectedBaseUrl, 107 | id: "app-id", 108 | name: "Test app", 109 | permissions: [], 110 | tokenTargetUrl: `${expectedBaseUrl}/api/register`, 111 | version: "1", 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/create-manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateManifestHandlerOptions as GenericHandlerOptions, 3 | ManifestActionHandler, 4 | } from "@/handlers/actions/manifest-action-handler"; 5 | 6 | import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; 7 | 8 | export type CreateManifestHandlerOptions = GenericHandlerOptions; 9 | 10 | /** Returns app manifest API route handler for AWS Lambda HTTP triggered events 11 | * (created by Amazon API Gateway, Lambda Function URL) 12 | * that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2 13 | * 14 | * App manifest is an endpoint that Saleor will call your App metadata. 15 | * It has the App's name and description, as well as all the necessary information to 16 | * register webhooks, permissions, and extensions. 17 | * 18 | * **Recommended path**: `/api/manifest` 19 | * 20 | * To learn more check Saleor docs 21 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} 22 | * */ 23 | export const createManifestHandler = 24 | (config: CreateManifestHandlerOptions): AWSLambdaHandler => 25 | async (event, context) => { 26 | const adapter = new AwsLambdaAdapter(event, context); 27 | const actionHandler = new ManifestActionHandler(adapter); 28 | const result = await actionHandler.handleAction(config); 29 | return adapter.send(result); 30 | }; 31 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/create-protected-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { 4 | ProtectedActionValidator, 5 | ProtectedHandlerContext, 6 | } from "@/handlers/shared/protected-action-validator"; 7 | import { MockAPL } from "@/test-utils/mock-apl"; 8 | import { Permission } from "@/types"; 9 | 10 | import { AwsLambdaProtectedHandler, createProtectedHandler } from "./create-protected-handler"; 11 | import { createLambdaEvent, mockLambdaContext } from "./test-utils"; 12 | 13 | describe("AWS Lambda createProtectedHandler", () => { 14 | const mockAPL = new MockAPL(); 15 | const mockHandlerFn = vi.fn(() => ({ 16 | statusCode: 200, 17 | body: "success", 18 | })); 19 | 20 | const mockHandlerContext: ProtectedHandlerContext = { 21 | baseUrl: "https://example.com", 22 | authData: { 23 | token: mockAPL.mockToken, 24 | saleorApiUrl: "https://example.saleor.cloud/graphql/", 25 | appId: mockAPL.mockAppId, 26 | jwks: mockAPL.mockJwks, 27 | }, 28 | user: { 29 | email: "test@example.com", 30 | userPermissions: [], 31 | }, 32 | }; 33 | 34 | const event = createLambdaEvent({ 35 | headers: { 36 | host: "some-saleor-host.cloud", 37 | "x-forwarded-proto": "https", 38 | }, 39 | method: "GET", 40 | }); 41 | 42 | describe("validation", () => { 43 | it("sends error when request validation fails", async () => { 44 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 45 | result: "failure", 46 | value: { 47 | status: 401, 48 | body: "Unauthorized", 49 | bodyType: "string", 50 | }, 51 | }); 52 | 53 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 54 | const response = await handler(event, mockLambdaContext); 55 | 56 | expect(mockHandlerFn).not.toHaveBeenCalled(); 57 | expect(response.statusCode).toBe(401); 58 | }); 59 | 60 | it("calls handler function when validation succeeds", async () => { 61 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 62 | result: "ok", 63 | value: mockHandlerContext, 64 | }); 65 | 66 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 67 | await handler(event, mockLambdaContext); 68 | 69 | expect(mockHandlerFn).toHaveBeenCalledWith(event, mockLambdaContext, mockHandlerContext); 70 | }); 71 | }); 72 | 73 | describe("permissions handling", () => { 74 | it("checks if required permissions are satisfies using validator", async () => { 75 | const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); 76 | const requiredPermissions: Permission[] = ["MANAGE_APPS"]; 77 | 78 | const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); 79 | await handler(event, mockLambdaContext); 80 | 81 | expect(validateRequestSpy).toHaveBeenCalledWith({ 82 | apl: mockAPL, 83 | requiredPermissions, 84 | }); 85 | }); 86 | }); 87 | 88 | describe("error handling", () => { 89 | it("returns 500 status when user handler function throws error", async () => { 90 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 91 | result: "ok", 92 | value: mockHandlerContext, 93 | }); 94 | 95 | mockHandlerFn.mockImplementationOnce(() => { 96 | throw new Error("Test error"); 97 | }); 98 | 99 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 100 | const response = await handler(event, mockLambdaContext); 101 | 102 | expect(response.statusCode).toBe(500); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/create-protected-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2, Context } from "aws-lambda"; 2 | 3 | import { APL } from "@/APL"; 4 | import { 5 | ProtectedActionValidator, 6 | ProtectedHandlerContext, 7 | } from "@/handlers/shared/protected-action-validator"; 8 | import { Permission } from "@/types"; 9 | 10 | import { AwsLambdaAdapter, AWSLambdaHandler } from "./platform-adapter"; 11 | 12 | export type AwsLambdaProtectedHandler = ( 13 | event: APIGatewayProxyEventV2, 14 | context: Context, 15 | saleorContext: ProtectedHandlerContext 16 | ) => Promise | APIGatewayProxyStructuredResultV2; 17 | 18 | /** 19 | * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. 20 | * Also provides additional `saleorContext` object containing request properties. 21 | */ 22 | export const createProtectedHandler = 23 | ( 24 | handlerFn: AwsLambdaProtectedHandler, 25 | apl: APL, 26 | requiredPermissions?: Permission[] 27 | ): AWSLambdaHandler => 28 | async (event, context) => { 29 | const adapter = new AwsLambdaAdapter(event, context); 30 | const actionValidator = new ProtectedActionValidator(adapter); 31 | const validationResult = await actionValidator.validateRequest({ 32 | apl, 33 | requiredPermissions, 34 | }); 35 | 36 | if (validationResult.result === "failure") { 37 | return adapter.send(validationResult.value); 38 | } 39 | 40 | const saleorContext = validationResult.value; 41 | try { 42 | return await handlerFn(event, context, saleorContext); 43 | } catch (err) { 44 | return { 45 | statusCode: 500, 46 | body: "Unexpected Server Error", 47 | }; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-app-register-handler"; 2 | export * from "./create-manifest-handler"; 3 | export * from "./create-protected-handler"; 4 | export * from "./platform-adapter"; 5 | export * from "./saleor-webhooks/saleor-async-webhook"; 6 | export * from "./saleor-webhooks/saleor-sync-webhook"; 7 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/platform-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIGatewayProxyEventV2, 3 | APIGatewayProxyStructuredResultV2, 4 | Context, 5 | } from "aws-lambda"; 6 | 7 | import { 8 | ActionHandlerResult, 9 | HTTPMethod, 10 | PlatformAdapterInterface, 11 | } from "@/handlers/shared/generic-adapter-use-case-types"; 12 | 13 | export type AwsLambdaHandlerInput = APIGatewayProxyEventV2; 14 | export type AWSLambdaHandler = ( 15 | event: APIGatewayProxyEventV2, 16 | context: Context 17 | ) => Promise; 18 | 19 | /** PlatformAdapter for AWS Lambda HTTP events 20 | * 21 | * Platform adapters are used in Actions to handle generic request logic 22 | * like getting body, headers, etc. 23 | * 24 | * Thanks to this Actions logic can be re-used for each platform 25 | 26 | * @see {PlatformAdapterInterface} 27 | * */ 28 | export class AwsLambdaAdapter implements PlatformAdapterInterface { 29 | public request: AwsLambdaHandlerInput; 30 | 31 | constructor(private event: APIGatewayProxyEventV2, private context: Context) { 32 | this.request = event; 33 | } 34 | 35 | getHeader(requestedName: string): string | null { 36 | // TODO: Check if it works correctly with both API gateway and Lambda Function URL 37 | 38 | // Lambda headers are always in lowercase for new deployments using API Gateway, 39 | // they use HTTP/2 which requires them to be lowercase 40 | // 41 | // if there are multiple values they are separated by comma: , 42 | // https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format 43 | const name = requestedName.toLocaleLowerCase(); 44 | return this.request.headers[name] || null; 45 | } 46 | 47 | async getBody(): Promise { 48 | if (!this.request.body) { 49 | return null; 50 | } 51 | 52 | return JSON.parse(this.request.body); 53 | } 54 | 55 | async getRawBody(): Promise { 56 | if (!this.request.body) { 57 | return null; 58 | } 59 | 60 | return this.request.body; 61 | } 62 | 63 | // This stage name is used when no stage name is provided in AWS CDK 64 | // this means that stage name is not appended to the lambda URL 65 | private DEFAULT_STAGE_NAME = "$default"; 66 | 67 | getBaseUrl(): string { 68 | const xForwardedProto = this.getHeader("x-forwarded-proto") || "https"; 69 | const host = this.getHeader("host"); 70 | 71 | const xForwardedProtos = Array.isArray(xForwardedProto) 72 | ? xForwardedProto.join(",") 73 | : xForwardedProto; 74 | const protocols = xForwardedProtos.split(","); 75 | 76 | // prefer https, then http over other protocols 77 | const protocol = 78 | protocols.find((el) => el === "https") || 79 | protocols.find((el) => el === "http") || 80 | protocols[0]; 81 | 82 | // API Gateway splits deployment into multiple stages which are 83 | // included in the API url (e.g. /dev or /prod) 84 | // default stage name means that it's not appended to the URL 85 | // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html 86 | const { stage } = this.event.requestContext; 87 | 88 | if (stage && stage !== this.DEFAULT_STAGE_NAME) { 89 | return `${protocol}://${host}/${stage}`; 90 | } 91 | 92 | return `${protocol}://${host}`; 93 | } 94 | 95 | get method(): HTTPMethod { 96 | return this.event.requestContext.http.method as HTTPMethod; 97 | } 98 | 99 | async send(result: ActionHandlerResult): Promise { 100 | const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; 101 | 102 | return { 103 | statusCode: result.status, 104 | headers: { 105 | "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", 106 | }, 107 | body, 108 | }; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts: -------------------------------------------------------------------------------- 1 | import { AsyncWebhookEventType } from "@/types"; 2 | 3 | import { AWSLambdaHandler } from "../platform-adapter"; 4 | import { AwsLambdaWebhookHandler, SaleorWebApiWebhook, WebhookConfig } from "./saleor-webhook"; 5 | 6 | export class SaleorAsyncWebhook extends SaleorWebApiWebhook { 7 | readonly event: AsyncWebhookEventType; 8 | 9 | protected readonly eventType = "async" as const; 10 | 11 | constructor(configuration: WebhookConfig) { 12 | super(configuration); 13 | 14 | this.event = configuration.event; 15 | } 16 | 17 | createHandler(handlerFn: AwsLambdaWebhookHandler): AWSLambdaHandler { 18 | return super.createHandler(handlerFn); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts: -------------------------------------------------------------------------------- 1 | import { SyncWebhookEventType } from "@/types"; 2 | 3 | import { AWSLambdaHandler } from "../platform-adapter"; 4 | import { AwsLambdaWebhookHandler, SaleorWebApiWebhook, WebhookConfig } from "./saleor-webhook"; 5 | 6 | export type AwsLambdaSyncWebhookHandler< 7 | TPayload, 8 | > = AwsLambdaWebhookHandler; 9 | 10 | export class SaleorSyncWebhook< 11 | TPayload = unknown, 12 | TEvent extends SyncWebhookEventType = SyncWebhookEventType, 13 | > extends SaleorWebApiWebhook { 14 | readonly event: TEvent; 15 | 16 | protected readonly eventType = "sync" as const; 17 | 18 | 19 | constructor(configuration: WebhookConfig) { 20 | super(configuration); 21 | 22 | this.event = configuration.event; 23 | } 24 | 25 | createHandler(handlerFn: AwsLambdaSyncWebhookHandler): AWSLambdaHandler { 26 | return super.createHandler(handlerFn); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyStructuredResultV2, Context } from "aws-lambda"; 2 | 3 | import { createDebug } from "@/debug"; 4 | import { 5 | GenericSaleorWebhook, 6 | GenericWebhookConfig, 7 | } from "@/handlers/shared/generic-saleor-webhook"; 8 | import { WebhookContext } from "@/handlers/shared/saleor-webhook"; 9 | import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; 10 | 11 | import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "../platform-adapter"; 12 | 13 | const debug = createDebug("SaleorWebhook"); 14 | 15 | export type WebhookConfig = 16 | GenericWebhookConfig; 17 | 18 | /** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ 19 | export type AwsLambdaWebhookHandler = ( 20 | event: AwsLambdaHandlerInput, 21 | context: Context, 22 | ctx: WebhookContext, 23 | ) => Promise | APIGatewayProxyStructuredResultV2; 24 | 25 | export abstract class SaleorWebApiWebhook extends GenericSaleorWebhook< 26 | AwsLambdaHandlerInput, 27 | TPayload 28 | > { 29 | /** 30 | * Wraps provided function, to ensure incoming request comes from registered Saleor instance. 31 | * Also provides additional `context` object containing typed payload and request properties. 32 | */ 33 | createHandler(handlerFn: AwsLambdaWebhookHandler): AWSLambdaHandler { 34 | return async (event, context) => { 35 | const adapter = new AwsLambdaAdapter(event, context); 36 | const prepareRequestResult = await super.prepareRequest(adapter); 37 | 38 | if (prepareRequestResult.result === "sendResponse") { 39 | return prepareRequestResult.response; 40 | } 41 | 42 | debug("Incoming request validated. Call handlerFn"); 43 | return handlerFn(event, context, { 44 | ...prepareRequestResult.context, 45 | }); 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/handlers/platforms/aws-lambda/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEventV2, Context } from "aws-lambda"; 2 | import { vi } from "vitest"; 3 | 4 | export const mockLambdaContext: Context = { 5 | callbackWaitsForEmptyEventLoop: false, 6 | functionName: "testFunction", 7 | functionVersion: "1", 8 | invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789012:function:test", 9 | memoryLimitInMB: "128", 10 | awsRequestId: "test-request-id", 11 | logGroupName: "test-log-group", 12 | logStreamName: "test-log-stream", 13 | getRemainingTimeInMillis: () => 10000, 14 | done: vi.fn(), 15 | fail: vi.fn(), 16 | succeed: vi.fn(), 17 | }; 18 | 19 | export function createLambdaEvent( 20 | config: Omit, "requestContext"> & { 21 | requestContext?: Partial; 22 | path?: string; 23 | method?: "POST" | "GET"; 24 | } = {} 25 | ): APIGatewayProxyEventV2 { 26 | const { 27 | path = "/some-path", 28 | method = "POST", 29 | requestContext: requestContextOverrides, 30 | ...overrides 31 | } = config ?? {}; 32 | 33 | return { 34 | version: "2.0", 35 | routeKey: `${method} ${path}`, 36 | rawPath: path, 37 | rawQueryString: "", 38 | headers: {}, 39 | body: "", 40 | isBase64Encoded: false, 41 | ...overrides, 42 | requestContext: { 43 | accountId: "123456789012", 44 | apiId: "api-id", 45 | domainName: "example.com", 46 | domainPrefix: "example", 47 | http: { 48 | method, 49 | path, 50 | protocol: "HTTP/1.1", 51 | sourceIp: "192.168.0.1", 52 | userAgent: "vitest-test", 53 | ...requestContextOverrides?.http, 54 | }, 55 | requestId: "test-request-id", 56 | routeKey: `${method} /${path}`, 57 | stage: "$default", 58 | time: "03/Feb/2025:16:00:00 +0000", 59 | timeEpoch: Date.now(), 60 | ...requestContextOverrides, 61 | }, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/create-app-register-handler.ts: -------------------------------------------------------------------------------- 1 | import { RegisterActionHandler } from "@/handlers/actions/register-action-handler"; 2 | import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; 3 | 4 | import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; 5 | 6 | export type CreateAppRegisterHandlerOptions = 7 | GenericCreateAppRegisterHandlerOptions; 8 | 9 | /** 10 | * Returns API route handler for Web API compatible request handlers 11 | * (examples: Next.js app router, hono, deno, etc.) 12 | * that use signature: (req: Request) => Response 13 | * where Request and Response are Fetch API objects 14 | * 15 | * Handler is for register endpoint that is called by Saleor when installing the app 16 | * 17 | * It verifies the request and stores `app_token` from Saleor 18 | * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) 19 | * 20 | * **Recommended path**: `/api/register` 21 | * (configured in manifest handler) 22 | * 23 | * To learn more check Saleor docs 24 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} 25 | * 26 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} 27 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} 28 | * */ 29 | export const createAppRegisterHandler = 30 | (config: CreateAppRegisterHandlerOptions): WebApiHandler => 31 | async (req) => { 32 | const adapter = new WebApiAdapter(req, Response); 33 | const useCase = new RegisterActionHandler(adapter); 34 | const result = await useCase.handleAction(config); 35 | return adapter.send(result); 36 | }; 37 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/create-manifest-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { SALEOR_SCHEMA_VERSION_HEADER } from "@/headers"; 4 | 5 | import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; 6 | 7 | describe("Fetch API createManifestHandler", () => { 8 | it("Creates a handler that responds with manifest, includes a request and baseUrl in factory method", async () => { 9 | const baseUrl = "https://some-app-host.cloud"; 10 | const request = new Request(baseUrl, { 11 | headers: { 12 | host: "some-app-host.cloud", 13 | "x-forwarded-proto": "https", 14 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 15 | }, 16 | method: "GET", 17 | }); 18 | 19 | const mockManifestFactory = vi 20 | .fn() 21 | .mockImplementation(({ appBaseUrl }) => ({ 22 | name: "Test app", 23 | tokenTargetUrl: `${appBaseUrl}/api/register`, 24 | appUrl: appBaseUrl, 25 | permissions: [], 26 | id: "app-id", 27 | version: "1", 28 | })); 29 | 30 | const handler = createManifestHandler({ 31 | manifestFactory: mockManifestFactory, 32 | }); 33 | 34 | const response = await handler(request); 35 | 36 | expect(mockManifestFactory).toHaveBeenCalledWith({ 37 | appBaseUrl: baseUrl, 38 | request, 39 | schemaVersion: [3, 20], 40 | }); 41 | expect(response.status).toBe(200); 42 | await expect(response.json()).resolves.toStrictEqual({ 43 | appUrl: "https://some-app-host.cloud", 44 | id: "app-id", 45 | name: "Test app", 46 | permissions: [], 47 | tokenTargetUrl: "https://some-app-host.cloud/api/register", 48 | version: "1", 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/create-manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateManifestHandlerOptions as GenericHandlerOptions, 3 | ManifestActionHandler, 4 | } from "@/handlers/actions/manifest-action-handler"; 5 | 6 | import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; 7 | 8 | export type CreateManifestHandlerOptions = GenericHandlerOptions; 9 | 10 | /** Returns app manifest API route handler for Web API compatible request handlers 11 | * (examples: Next.js app router, hono, deno, etc.) 12 | * that use signature: (req: Request) => Response 13 | * where Request and Response are Fetch API objects 14 | * 15 | * App manifest is an endpoint that Saleor will call your App metadata. 16 | * It has the App's name and description, as well as all the necessary information to 17 | * register webhooks, permissions, and extensions. 18 | * 19 | * **Recommended path**: `/api/manifest` 20 | * 21 | * To learn more check Saleor docs 22 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} 23 | * 24 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} 25 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} 26 | * */ 27 | export const createManifestHandler = 28 | (config: CreateManifestHandlerOptions): WebApiHandler => 29 | async (request: Request) => { 30 | const adapter = new WebApiAdapter(request, Response); 31 | const actionHandler = new ManifestActionHandler(adapter); 32 | const result = await actionHandler.handleAction(config); 33 | return adapter.send(result); 34 | }; 35 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/create-protected-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { 4 | ProtectedActionValidator, 5 | ProtectedHandlerContext, 6 | } from "@/handlers/shared/protected-action-validator"; 7 | import { MockAPL } from "@/test-utils/mock-apl"; 8 | import { Permission } from "@/types"; 9 | 10 | import { createProtectedHandler } from "./create-protected-handler"; 11 | 12 | describe("Web API createProtectedHandler", () => { 13 | const mockAPL = new MockAPL(); 14 | const mockHandlerFn = vi.fn(); 15 | 16 | const mockHandlerContext: ProtectedHandlerContext = { 17 | baseUrl: "https://example.com", 18 | authData: { 19 | token: mockAPL.mockToken, 20 | saleorApiUrl: "https://example.saleor.cloud/graphql/", 21 | appId: mockAPL.mockAppId, 22 | jwks: mockAPL.mockJwks, 23 | }, 24 | user: { 25 | email: "test@example.com", 26 | userPermissions: [], 27 | }, 28 | }; 29 | 30 | let request: Request; 31 | 32 | beforeEach(() => { 33 | request = new Request("https://example.com", { 34 | headers: { 35 | host: "some-saleor-host.cloud", 36 | "x-forwarded-proto": "https", 37 | }, 38 | method: "GET", 39 | }); 40 | }); 41 | 42 | describe("validation", () => { 43 | it("sends error when request validation fails", async () => { 44 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 45 | result: "failure", 46 | value: { 47 | status: 401, 48 | body: "Unauthorized", 49 | bodyType: "string", 50 | }, 51 | }); 52 | 53 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 54 | const response = await handler(request); 55 | 56 | expect(mockHandlerFn).not.toHaveBeenCalled(); 57 | expect(response.status).toBe(401); 58 | }); 59 | 60 | it("calls handler function when validation succeeds", async () => { 61 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 62 | result: "ok", 63 | value: mockHandlerContext, 64 | }); 65 | 66 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 67 | await handler(request); 68 | 69 | expect(mockHandlerFn).toHaveBeenCalledWith(request, mockHandlerContext); 70 | }); 71 | }); 72 | 73 | describe("permissions handling", () => { 74 | it("passes required permissions from factory input to validator", async () => { 75 | const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); 76 | const requiredPermissions: Permission[] = ["MANAGE_APPS"]; 77 | 78 | const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); 79 | await handler(request); 80 | 81 | expect(validateRequestSpy).toHaveBeenCalledWith({ 82 | apl: mockAPL, 83 | requiredPermissions, 84 | }); 85 | }); 86 | }); 87 | 88 | describe("error handling", () => { 89 | it("returns 500 status when user handler function throws error", async () => { 90 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 91 | result: "ok", 92 | value: mockHandlerContext, 93 | }); 94 | 95 | mockHandlerFn.mockImplementationOnce(() => { 96 | throw new Error("Test error"); 97 | }); 98 | 99 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 100 | const response = await handler(request); 101 | 102 | expect(response.status).toBe(500); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/create-protected-handler.ts: -------------------------------------------------------------------------------- 1 | import { APL } from "@/APL"; 2 | import { 3 | ProtectedActionValidator, 4 | ProtectedHandlerContext, 5 | } from "@/handlers/shared/protected-action-validator"; 6 | import { Permission } from "@/types"; 7 | 8 | import { WebApiAdapter, WebApiHandler } from "./platform-adapter"; 9 | 10 | export type WebApiProtectedHandler = ( 11 | request: Request, 12 | ctx: ProtectedHandlerContext, 13 | ) => Response | Promise; 14 | 15 | export const createProtectedHandler = 16 | ( 17 | handlerFn: WebApiProtectedHandler, 18 | apl: APL, 19 | requiredPermissions?: Permission[], 20 | ): WebApiHandler => 21 | async (request) => { 22 | const adapter = new WebApiAdapter(request, Response); 23 | const actionValidator = new ProtectedActionValidator(adapter); 24 | const validationResult = await actionValidator.validateRequest({ 25 | apl, 26 | requiredPermissions, 27 | }); 28 | 29 | if (validationResult.result === "failure") { 30 | return adapter.send(validationResult.value); 31 | } 32 | 33 | const context = validationResult.value; 34 | try { 35 | return await handlerFn(request, context); 36 | } catch (err) { 37 | return new Response("Unexpected server error", { status: 500 }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-app-register-handler"; 2 | export * from "./create-manifest-handler"; 3 | export * from "./create-protected-handler"; 4 | export * from "./platform-adapter"; 5 | export * from "./saleor-webhooks/saleor-async-webhook"; 6 | export * from "./saleor-webhooks/saleor-sync-webhook"; 7 | export { WebApiWebhookHandler } from "./saleor-webhooks/saleor-webhook"; 8 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/platform-adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionHandlerResult, 3 | PlatformAdapterInterface, 4 | } from "@/handlers/shared/generic-adapter-use-case-types"; 5 | 6 | export type WebApiHandlerInput = Request; 7 | export type WebApiHandler = (req: Request) => Response | Promise; 8 | 9 | /** PlatformAdapter for Web API (Fetch API: Request and Response) 10 | * 11 | * Platform adapters are used in Actions to handle generic request logic 12 | * like getting body, headers, etc. 13 | * 14 | * Thanks to this Actions logic can be re-used for each platform 15 | 16 | * @see {PlatformAdapterInterface} 17 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} 18 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} 19 | * 20 | * */ 21 | export class WebApiAdapter< 22 | TRequest extends Request = Request, 23 | TResponse extends Response = Response, 24 | > implements PlatformAdapterInterface 25 | { 26 | constructor( 27 | public request: TRequest, 28 | // todo how to type this nightmare 29 | // maybe instead of constructor, pass instance and clone it? 30 | public ResponseConstructor: { new (...args: any): TResponse }, 31 | ) {} 32 | 33 | getHeader(name: string) { 34 | return this.request.headers.get(name); 35 | } 36 | 37 | async getBody() { 38 | const request = this.request.clone(); 39 | 40 | return request.json(); 41 | } 42 | 43 | async getRawBody() { 44 | const request = this.request.clone(); 45 | return request.text(); 46 | } 47 | 48 | getBaseUrl(): string { 49 | const url = new URL(this.request.url); // This is safe, URL in Request object must be valid 50 | const host = this.request.headers.get("host"); 51 | const xForwardedProto = this.request.headers.get("x-forwarded-proto"); 52 | 53 | let protocol: string; 54 | if (xForwardedProto) { 55 | const xForwardedForProtocols = xForwardedProto.split(",").map((value) => value.trimStart()); 56 | protocol = 57 | xForwardedForProtocols.find((el) => el === "https") || 58 | xForwardedForProtocols.find((el) => el === "http") || 59 | xForwardedForProtocols[0]; 60 | } else { 61 | // Some providers (e.g. Deno Deploy) 62 | // do not set x-forwarded-for header when handling request 63 | // try to get it from URL 64 | protocol = url.protocol.replace(":", ""); 65 | } 66 | 67 | return `${protocol}://${host}`; 68 | } 69 | 70 | get method() { 71 | return this.request.method as "POST" | "GET"; 72 | } 73 | 74 | async send(result: ActionHandlerResult): Promise { 75 | const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; 76 | 77 | return new this.ResponseConstructor(body, { 78 | status: result.status, 79 | headers: { 80 | "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", 81 | }, 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { FormatWebhookErrorResult } from "@/handlers/shared"; 4 | import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; 5 | import { MockAPL } from "@/test-utils/mock-apl"; 6 | import { AsyncWebhookEventType } from "@/types"; 7 | 8 | import { SaleorAsyncWebhook } from "./saleor-async-webhook"; 9 | import { WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; 10 | 11 | const webhookPath = "api/webhooks/product-updated"; 12 | const baseUrl = "http://example.com"; 13 | 14 | describe("Web API SaleorAsyncWebhook", () => { 15 | const mockAPL = new MockAPL(); 16 | 17 | const validConfig: WebhookConfig = { 18 | apl: mockAPL, 19 | event: "PRODUCT_UPDATED", 20 | webhookPath, 21 | query: "subscription { event { ... on ProductUpdated { product { id }}}}", 22 | } as const; 23 | 24 | afterEach(() => { 25 | vi.restoreAllMocks(); 26 | }); 27 | 28 | describe("createHandler", () => { 29 | it("validates request before passing it to provided handler function with context", async () => { 30 | vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ 31 | result: "ok", 32 | context: { 33 | baseUrl: "example.com", 34 | event: "product_updated", 35 | payload: { data: "test_payload" }, 36 | schemaVersion: [3, 20], 37 | authData: { 38 | saleorApiUrl: mockAPL.workingSaleorApiUrl, 39 | token: mockAPL.mockToken, 40 | jwks: mockAPL.mockJwks, 41 | appId: mockAPL.mockAppId, 42 | }, 43 | }, 44 | }); 45 | 46 | const handler = vi 47 | .fn() 48 | .mockImplementation(() => new Response("OK", { status: 200 })); 49 | 50 | const webhook = new SaleorAsyncWebhook(validConfig); 51 | const request = new Request(`${baseUrl}/webhook`); 52 | 53 | const wrappedHandler = webhook.createHandler(handler); 54 | const response = await wrappedHandler(request); 55 | 56 | expect(response.status).toBe(200); 57 | expect(handler).toBeCalledTimes(1); 58 | expect(handler).toHaveBeenCalledWith( 59 | request, 60 | expect.objectContaining({ 61 | payload: { data: "test_payload" }, 62 | authData: expect.objectContaining({ 63 | saleorApiUrl: mockAPL.workingSaleorApiUrl, 64 | }), 65 | }), 66 | ); 67 | }); 68 | 69 | it("prevents handler execution when validation fails and returns error", async () => { 70 | vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ 71 | result: "failure", 72 | error: new Error("Test error"), 73 | }); 74 | 75 | const webhook = new SaleorAsyncWebhook(validConfig); 76 | const handler = vi.fn(); 77 | const request = new Request(`${baseUrl}/webhook`); 78 | 79 | const wrappedHandler = webhook.createHandler(handler); 80 | const response = await wrappedHandler(request); 81 | 82 | expect(response.status).toBe(500); 83 | await expect(response.text()).resolves.toBe("Unexpected error while handling request"); 84 | expect(handler).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it("should allow overriding error responses using formatErrorResponse", async () => { 88 | vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ 89 | result: "failure", 90 | error: new Error("Test error"), 91 | }); 92 | 93 | const mockFormatErrorResponse = vi.fn().mockResolvedValue({ 94 | body: "Custom error", 95 | code: 418, 96 | } as FormatWebhookErrorResult); 97 | const webhook = new SaleorAsyncWebhook({ 98 | ...validConfig, 99 | formatErrorResponse: mockFormatErrorResponse, 100 | }); 101 | 102 | const request = new Request(`${baseUrl}/webhook`, { 103 | method: "POST", 104 | headers: { "saleor-event": "invalid_event" }, 105 | }); 106 | 107 | const handler = vi.fn(); 108 | const wrappedHandler = webhook.createHandler(handler); 109 | const response = await wrappedHandler(request); 110 | 111 | expect(response.status).toBe(418); 112 | await expect(response.text()).resolves.toBe("Custom error"); 113 | expect(handler).not.toHaveBeenCalled(); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts: -------------------------------------------------------------------------------- 1 | import { AsyncWebhookEventType } from "@/types"; 2 | 3 | import { WebApiHandler } from "../platform-adapter"; 4 | import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; 5 | 6 | export class SaleorAsyncWebhook extends SaleorWebApiWebhook { 7 | readonly event: AsyncWebhookEventType; 8 | 9 | protected readonly eventType = "async" as const; 10 | 11 | constructor(configuration: WebhookConfig) { 12 | super(configuration); 13 | 14 | this.event = configuration.event; 15 | } 16 | 17 | createHandler(handlerFn: WebApiWebhookHandler): WebApiHandler { 18 | return super.createHandler(handlerFn); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts: -------------------------------------------------------------------------------- 1 | import { SyncWebhookEventType } from "@/types"; 2 | 3 | import { WebApiHandler } from "../platform-adapter"; 4 | import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook"; 5 | 6 | export type WebApiSyncWebhookHandler = WebApiWebhookHandler; 7 | 8 | export class SaleorSyncWebhook< 9 | TPayload = unknown, 10 | TEvent extends SyncWebhookEventType = SyncWebhookEventType, 11 | > extends SaleorWebApiWebhook { 12 | readonly event: TEvent; 13 | 14 | protected readonly eventType = "sync" as const; 15 | 16 | constructor(configuration: WebhookConfig) { 17 | super(configuration); 18 | 19 | this.event = configuration.event; 20 | } 21 | 22 | createHandler(handlerFn: WebApiSyncWebhookHandler): WebApiHandler { 23 | return super.createHandler(handlerFn); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "@/debug"; 2 | import { 3 | GenericSaleorWebhook, 4 | GenericWebhookConfig, 5 | } from "@/handlers/shared/generic-saleor-webhook"; 6 | import { WebhookContext } from "@/handlers/shared/saleor-webhook"; 7 | import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; 8 | 9 | import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "../platform-adapter"; 10 | 11 | const debug = createDebug("SaleorWebhook"); 12 | 13 | export type WebhookConfig = 14 | GenericWebhookConfig; 15 | 16 | /** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ 17 | export type WebApiWebhookHandler< 18 | TPayload = unknown, 19 | TRequest extends Request = Request, 20 | TResponse extends Response = Response, 21 | > = (req: TRequest, ctx: WebhookContext) => TResponse | Promise; 22 | 23 | export abstract class SaleorWebApiWebhook extends GenericSaleorWebhook< 24 | WebApiHandlerInput, 25 | TPayload 26 | > { 27 | createHandler(handlerFn: WebApiWebhookHandler): WebApiHandler { 28 | return async (req) => { 29 | const adapter = new WebApiAdapter(req, Response); 30 | const prepareRequestResult = await super.prepareRequest(adapter); 31 | 32 | if (prepareRequestResult.result === "sendResponse") { 33 | return prepareRequestResult.response; 34 | } 35 | 36 | debug("Incoming request validated. Call handlerFn"); 37 | return handlerFn(req, { 38 | ...prepareRequestResult.context, 39 | }); 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/create-app-register-handler.ts: -------------------------------------------------------------------------------- 1 | import { RegisterActionHandler } from "@/handlers/actions/register-action-handler"; 2 | import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; 3 | 4 | import { 5 | NextAppRouterAdapter, 6 | NextAppRouterHandler, 7 | NextAppRouterHandlerInput, 8 | } from "./platform-adapter"; 9 | 10 | export type CreateAppRegisterHandlerOptions = 11 | GenericCreateAppRegisterHandlerOptions; 12 | 13 | export const createAppRegisterHandler = 14 | (config: CreateAppRegisterHandlerOptions): NextAppRouterHandler => 15 | async (req) => { 16 | const adapter = new NextAppRouterAdapter(req); 17 | const useCase = new RegisterActionHandler(adapter); 18 | const result = await useCase.handleAction(config); 19 | 20 | return adapter.send(result); 21 | }; 22 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/create-manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server.js"; 2 | 3 | import { 4 | CreateManifestHandlerOptions as GenericHandlerOptions, 5 | ManifestActionHandler, 6 | } from "@/handlers/actions/manifest-action-handler"; 7 | 8 | import { 9 | NextAppRouterAdapter, 10 | NextAppRouterHandler, 11 | NextAppRouterHandlerInput, 12 | } from "./platform-adapter"; 13 | 14 | export type CreateManifestHandlerOptions = GenericHandlerOptions; 15 | 16 | /** Returns app manifest API route handler for Web API compatible request handlers 17 | * (examples: Next.js app router, hono, deno, etc.) 18 | * that use signature: (req: Request) => Response 19 | * where Request and Response are Fetch API objects 20 | * 21 | * App manifest is an endpoint that Saleor will call your App metadata. 22 | * It has the App's name and description, as well as all the necessary information to 23 | * register webhooks, permissions, and extensions. 24 | * 25 | * **Recommended path**: `/api/manifest` 26 | * 27 | * To learn more check Saleor docs 28 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} 29 | * 30 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} 31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} 32 | * */ 33 | export const createManifestHandler = 34 | (config: CreateManifestHandlerOptions): NextAppRouterHandler => 35 | async (request: NextRequest) => { 36 | const adapter = new NextAppRouterAdapter(request); 37 | const actionHandler = new ManifestActionHandler(adapter); 38 | const result = await actionHandler.handleAction(config); 39 | return adapter.send(result); 40 | }; 41 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/create-protected-handler.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server.js"; 2 | 3 | import { APL } from "@/APL"; 4 | import { 5 | ProtectedActionValidator, 6 | ProtectedHandlerContext, 7 | } from "@/handlers/shared/protected-action-validator"; 8 | import { Permission } from "@/types"; 9 | 10 | import { NextAppRouterAdapter, NextAppRouterHandler } from "./platform-adapter"; 11 | 12 | export type NextAppRouterProtectedHandler = ( 13 | request: NextRequest, 14 | ctx: ProtectedHandlerContext, 15 | ) => Response | Promise; 16 | 17 | export const createProtectedHandler = 18 | ( 19 | handlerFn: NextAppRouterProtectedHandler, 20 | apl: APL, 21 | requiredPermissions?: Permission[], 22 | ): NextAppRouterHandler => 23 | async (request) => { 24 | const adapter = new NextAppRouterAdapter(request); 25 | const actionValidator = new ProtectedActionValidator(adapter); 26 | const validationResult = await actionValidator.validateRequest({ 27 | apl, 28 | requiredPermissions, 29 | }); 30 | 31 | if (validationResult.result === "failure") { 32 | return adapter.send(validationResult.value); 33 | } 34 | 35 | const context = validationResult.value; 36 | try { 37 | return await handlerFn(request, context); 38 | } catch (err) { 39 | return new NextResponse("Unexpected server error", { status: 500 }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-app-register-handler"; 2 | export * from "./create-manifest-handler"; 3 | export * from "./create-protected-handler"; 4 | export * from "./platform-adapter"; 5 | export * from "./saleor-webhooks/saleor-async-webhook"; 6 | export * from "./saleor-webhooks/saleor-sync-webhook"; 7 | export { NextAppRouterWebhookHandler } from "./saleor-webhooks/saleor-webhook"; 8 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/platform-adapter.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server.js"; 2 | 3 | import { WebApiAdapter } from "@/handlers/platforms/fetch-api"; 4 | 5 | export type NextAppRouterHandlerInput = NextRequest; 6 | export type NextAppRouterHandler = (req: NextRequest) => Response | Promise; 7 | 8 | export class NextAppRouterAdapter extends WebApiAdapter { 9 | constructor(public request: NextRequest) { 10 | super(request, Response); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/saleor-webhooks/saleor-async-webhook.ts: -------------------------------------------------------------------------------- 1 | import { AsyncWebhookEventType } from "@/types"; 2 | 3 | import { NextAppRouterHandler } from "../platform-adapter"; 4 | import { 5 | NextAppRouterWebhookHandler, 6 | SaleorNextAppRouterWebhook, 7 | WebhookConfig, 8 | } from "./saleor-webhook"; 9 | 10 | export class SaleorAsyncWebhook extends SaleorNextAppRouterWebhook { 11 | readonly event: AsyncWebhookEventType; 12 | 13 | protected readonly eventType = "async" as const; 14 | 15 | constructor(configuration: WebhookConfig) { 16 | super(configuration); 17 | 18 | this.event = configuration.event; 19 | } 20 | 21 | createHandler(handlerFn: NextAppRouterWebhookHandler): NextAppRouterHandler { 22 | return super.createHandler(handlerFn); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/saleor-webhooks/saleor-sync-webhook.test.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server.js"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { FileAPL } from "@/APL/file"; 5 | import { SaleorSyncWebhook } from "@/handlers/platforms/next-app-router"; 6 | 7 | describe("SaleorSyncWebhook (Next App Router)", () => { 8 | it("Constructs (and types are right)", () => { 9 | /** 10 | * This test doesn't test anything in the runtime. 11 | * It's meant to ensure types are correct. If types are wrong, the project will not compile. 12 | */ 13 | expect.assertions(0); 14 | 15 | const handler = new SaleorSyncWebhook<{ foo: string }>({ 16 | apl: new FileAPL(), 17 | event: "CHECKOUT_CALCULATE_TAXES", 18 | name: "asd", 19 | query: "{}", 20 | webhookPath: "/", 21 | }); 22 | 23 | handler.createHandler((req, ctx) => { 24 | const { body } = req; 25 | 26 | const { event } = ctx; 27 | 28 | return NextResponse.json({ 29 | event, 30 | body, 31 | }); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/saleor-webhooks/saleor-sync-webhook.ts: -------------------------------------------------------------------------------- 1 | import { SyncWebhookEventType } from "@/types"; 2 | 3 | import { NextAppRouterHandler } from "../platform-adapter"; 4 | import { 5 | NextAppRouterWebhookHandler, 6 | SaleorNextAppRouterWebhook, 7 | WebhookConfig, 8 | } from "./saleor-webhook"; 9 | 10 | export type NextAppRouterSyncWebhookHandler = NextAppRouterWebhookHandler; 11 | 12 | export class SaleorSyncWebhook< 13 | TPayload = unknown, 14 | TEvent extends SyncWebhookEventType = SyncWebhookEventType, 15 | > extends SaleorNextAppRouterWebhook { 16 | readonly event: TEvent; 17 | 18 | protected readonly eventType = "sync" as const; 19 | 20 | constructor(configuration: WebhookConfig) { 21 | super(configuration); 22 | 23 | this.event = configuration.event; 24 | } 25 | 26 | createHandler(handlerFn: NextAppRouterSyncWebhookHandler): NextAppRouterHandler { 27 | return super.createHandler(handlerFn); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/handlers/platforms/next-app-router/saleor-webhooks/saleor-webhook.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server.js"; 2 | 3 | import { createDebug } from "@/debug"; 4 | import { WebApiWebhookHandler } from "@/handlers/platforms/fetch-api"; 5 | import { 6 | GenericSaleorWebhook, 7 | GenericWebhookConfig, 8 | } from "@/handlers/shared/generic-saleor-webhook"; 9 | import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; 10 | 11 | import { 12 | NextAppRouterAdapter, 13 | NextAppRouterHandler, 14 | NextAppRouterHandlerInput, 15 | } from "../platform-adapter"; 16 | 17 | const debug = createDebug("SaleorWebhook"); 18 | 19 | export type WebhookConfig = 20 | GenericWebhookConfig; 21 | 22 | export type NextAppRouterWebhookHandler< 23 | TPayload = unknown, 24 | TRequest extends NextRequest = NextRequest, 25 | TResponse extends Response = Response, 26 | > = WebApiWebhookHandler; 27 | 28 | export abstract class SaleorNextAppRouterWebhook extends GenericSaleorWebhook< 29 | NextAppRouterHandlerInput, 30 | TPayload 31 | > { 32 | createHandler(handlerFn: NextAppRouterWebhookHandler): NextAppRouterHandler { 33 | return async (req): Promise => { 34 | const adapter = new NextAppRouterAdapter(req); 35 | const prepareRequestResult = await super.prepareRequest(adapter); 36 | 37 | if (prepareRequestResult.result === "sendResponse") { 38 | return prepareRequestResult.response; 39 | } 40 | 41 | debug("Incoming request validated. Call handlerFn"); 42 | return handlerFn(req, { 43 | ...prepareRequestResult.context, 44 | }); 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/create-app-register-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RegisterActionHandler, 3 | } from "@/handlers/actions/register-action-handler"; 4 | import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; 5 | 6 | import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; 7 | 8 | export type CreateAppRegisterHandlerOptions = 9 | GenericCreateAppRegisterHandlerOptions; 10 | 11 | /** 12 | * Returns API route handler for **Next.js pages router** 13 | * for register endpoint that is called by Saleor when installing the app 14 | * 15 | * It verifies the request and stores `app_token` from Saleor 16 | * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) 17 | * 18 | * **Recommended path**: `/api/register` 19 | * (configured in manifest handler) 20 | * 21 | * To learn more check Saleor docs 22 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} 23 | * */ 24 | export const createAppRegisterHandler = 25 | (config: CreateAppRegisterHandlerOptions): NextJsHandler => 26 | async (req, res) => { 27 | const adapter = new NextJsAdapter(req, res); 28 | const actionHandler = new RegisterActionHandler(adapter); 29 | const result = await actionHandler.handleAction(config); 30 | return adapter.send(result); 31 | }; 32 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/create-manifest-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { createMocks } from "node-mocks-http"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | 4 | import { SALEOR_SCHEMA_VERSION_HEADER } from "@/headers"; 5 | 6 | import { createManifestHandler, CreateManifestHandlerOptions } from "./create-manifest-handler"; 7 | 8 | describe("Next.js createManifestHandler", () => { 9 | it("Creates a handler that responds with Manifest. Includes request in context", async () => { 10 | const baseUrl = "https://some-app-host.cloud"; 11 | 12 | const { res, req } = createMocks({ 13 | headers: { 14 | host: "some-app-host.cloud", 15 | "x-forwarded-proto": "https", 16 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 17 | }, 18 | method: "GET", 19 | }); 20 | 21 | const mockManifestFactory = vi 22 | .fn() 23 | .mockImplementation(({ appBaseUrl }) => ({ 24 | name: "Test app", 25 | tokenTargetUrl: `${appBaseUrl}/api/register`, 26 | appUrl: appBaseUrl, 27 | permissions: [], 28 | id: "app-id", 29 | version: "1", 30 | })); 31 | 32 | const handler = createManifestHandler({ 33 | manifestFactory: mockManifestFactory, 34 | }); 35 | 36 | await handler(req, res); 37 | 38 | expect(mockManifestFactory).toHaveBeenCalledWith({ 39 | appBaseUrl: baseUrl, 40 | request: req, 41 | schemaVersion: [3, 20], 42 | }); 43 | 44 | expect(res.statusCode).toBe(200); 45 | 46 | expect(res._getJSONData()).toEqual({ 47 | appUrl: "https://some-app-host.cloud", 48 | id: "app-id", 49 | name: "Test app", 50 | permissions: [], 51 | tokenTargetUrl: "https://some-app-host.cloud/api/register", 52 | version: "1", 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/create-manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateManifestHandlerOptions as GenericCreateManifestHandlerOptions, 3 | ManifestActionHandler, 4 | } from "@/handlers/actions/manifest-action-handler"; 5 | 6 | import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; 7 | 8 | export type CreateManifestHandlerOptions = GenericCreateManifestHandlerOptions; 9 | 10 | /** Returns app manifest API route handler for Next.js pages router 11 | * 12 | * App manifest is an endpoint that Saleor will call your App metadata. 13 | * It has the App's name and description, as well as all the necessary information to 14 | * register webhooks, permissions, and extensions. 15 | * 16 | * **Recommended path**: `/api/manifest` 17 | * 18 | * To learn more check Saleor docs 19 | * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#manifest-url} 20 | * @see {@link https://nextjs.org/docs/pages/building-your-application/routing/api-routes} 21 | * */ 22 | export const createManifestHandler = 23 | (options: CreateManifestHandlerOptions): NextJsHandler => 24 | async (req, res) => { 25 | const adapter = new NextJsAdapter(req, res); 26 | const actionHandler = new ManifestActionHandler(adapter); 27 | const result = await actionHandler.handleAction(options); 28 | return adapter.send(result); 29 | }; 30 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/create-protected-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { createMocks } from "node-mocks-http"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | 4 | import { 5 | ProtectedActionValidator, 6 | ProtectedHandlerContext, 7 | } from "@/handlers/shared/protected-action-validator"; 8 | import { MockAPL } from "@/test-utils/mock-apl"; 9 | import { Permission } from "@/types"; 10 | 11 | import { createProtectedHandler } from "./create-protected-handler"; 12 | 13 | describe("Next.js createProtectedHandler", () => { 14 | const mockAPL = new MockAPL(); 15 | const mockHandlerFn = vi.fn(); 16 | const { req, res } = createMocks({ 17 | headers: { 18 | host: "some-saleor-host.cloud", 19 | "x-forwarded-proto": "https", 20 | }, 21 | method: "GET", 22 | }); 23 | const mockHandlerContext: ProtectedHandlerContext = { 24 | baseUrl: "https://example.com", 25 | authData: { 26 | token: mockAPL.mockToken, 27 | saleorApiUrl: "https://example.saleor.cloud/graphql/", 28 | appId: mockAPL.mockAppId, 29 | jwks: mockAPL.mockJwks, 30 | }, 31 | user: { 32 | email: "test@example.com", 33 | userPermissions: [], 34 | }, 35 | }; 36 | 37 | describe("validation", () => { 38 | it("sends error when request validation fails", async () => { 39 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 40 | result: "failure", 41 | value: { 42 | status: 401, 43 | body: "Unauthorized", 44 | bodyType: "string", 45 | }, 46 | }); 47 | 48 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 49 | await handler(req, res); 50 | 51 | expect(mockHandlerFn).not.toHaveBeenCalled(); 52 | expect(res._getStatusCode()).toBe(401); 53 | }); 54 | 55 | it("calls handler function when validation succeeds", async () => { 56 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 57 | result: "ok", 58 | value: mockHandlerContext, 59 | }); 60 | 61 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 62 | await handler(req, res); 63 | 64 | expect(mockHandlerFn).toHaveBeenCalledWith(req, res, mockHandlerContext); 65 | }); 66 | }); 67 | 68 | describe("permissions handling", () => { 69 | it("passes required permissions from factory input to validator", async () => { 70 | const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); 71 | const requiredPermissions: Permission[] = ["MANAGE_APPS"]; 72 | 73 | const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); 74 | await handler(req, res); 75 | 76 | expect(validateRequestSpy).toHaveBeenCalledWith({ 77 | apl: mockAPL, 78 | requiredPermissions, 79 | }); 80 | }); 81 | }); 82 | 83 | describe("error handling", () => { 84 | it("returns 500 status when user handler function throws error", async () => { 85 | vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ 86 | result: "ok", 87 | value: mockHandlerContext, 88 | }); 89 | 90 | mockHandlerFn.mockImplementationOnce(() => { 91 | throw new Error("Test error"); 92 | }); 93 | 94 | const handler = createProtectedHandler(mockHandlerFn, mockAPL); 95 | await handler(req, res); 96 | 97 | expect(res._getStatusCode()).toBe(500); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/create-protected-handler.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { APL } from "@/APL"; 4 | import { 5 | ProtectedActionValidator, 6 | ProtectedHandlerContext, 7 | } from "@/handlers/shared/protected-action-validator"; 8 | import { Permission } from "@/types"; 9 | 10 | import { NextJsAdapter } from "./platform-adapter"; 11 | 12 | export type NextJsProtectedApiHandler = ( 13 | req: NextApiRequest, 14 | res: NextApiResponse, 15 | ctx: ProtectedHandlerContext 16 | ) => unknown | Promise; 17 | 18 | /** 19 | * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. 20 | * Also provides additional `context` object containing request properties. 21 | */ 22 | export const createProtectedHandler = 23 | ( 24 | handlerFn: NextJsProtectedApiHandler, 25 | apl: APL, 26 | requiredPermissions?: Permission[] 27 | ): NextApiHandler => 28 | async (req, res) => { 29 | const adapter = new NextJsAdapter(req, res); 30 | const actionValidator = new ProtectedActionValidator(adapter); 31 | const validationResult = await actionValidator.validateRequest({ 32 | apl, 33 | requiredPermissions, 34 | }); 35 | 36 | if (validationResult.result === "failure") { 37 | return adapter.send(validationResult.value); 38 | } 39 | 40 | const context = validationResult.value; 41 | try { 42 | return handlerFn(req, res, context); 43 | } catch (err) { 44 | return res.status(500).end(); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-app-register-handler"; 2 | export * from "./create-manifest-handler"; 3 | export * from "./create-protected-handler"; 4 | export * from "./platform-adapter"; 5 | export * from "./saleor-webhooks/saleor-async-webhook"; 6 | export * from "./saleor-webhooks/saleor-sync-webhook"; 7 | export { NextJsWebhookHandler } from "./saleor-webhooks/saleor-webhook"; 8 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/platform-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { createMocks } from "node-mocks-http"; 2 | import getRawBody from "raw-body"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | 5 | import { NextJsAdapter } from "./platform-adapter"; 6 | 7 | vi.mock("raw-body"); 8 | 9 | describe("NextJsAdapter", () => { 10 | describe("getHeader", () => { 11 | it("should return single header value", () => { 12 | const { req, res } = createMocks({ 13 | headers: { 14 | "content-type": "application/json" 15 | } 16 | }); 17 | const adapter = new NextJsAdapter(req, res); 18 | expect(adapter.getHeader("content-type")).toBe("application/json"); 19 | }); 20 | 21 | it("should join multiple header values", () => { 22 | const { req, res } = createMocks({ 23 | headers: { 24 | // @ts-expect-error node-mocks-http types != real NextJsRequest 25 | "accept": ["text/html", "application/json"] 26 | } 27 | }); 28 | const adapter = new NextJsAdapter(req, res); 29 | expect(adapter.getHeader("accept")).toBe("text/html, application/json"); 30 | }); 31 | 32 | it("should return null for non-existent header", () => { 33 | const { req, res } = createMocks(); 34 | const adapter = new NextJsAdapter(req, res); 35 | expect(adapter.getHeader("non-existent")).toBeNull(); 36 | }); 37 | }); 38 | 39 | describe("getBody", () => { 40 | it("should return request body", async () => { 41 | const { req, res } = createMocks({ 42 | method: "POST", 43 | body: { data: "test" } 44 | }); 45 | const adapter = new NextJsAdapter(req, res); 46 | const body = await adapter.getBody(); 47 | expect(body).toEqual({ data: "test" }); 48 | }); 49 | }); 50 | 51 | describe("getRawBody", () => { 52 | it("should return raw body string", async () => { 53 | const { req, res } = createMocks({ 54 | headers: { 55 | "content-length": "10" 56 | } 57 | }); 58 | const adapter = new NextJsAdapter(req, res); 59 | 60 | vi.mocked(getRawBody).mockResolvedValueOnce(Buffer.from("test body")); 61 | 62 | const result = await adapter.getRawBody(); 63 | expect(result).toBe("test body"); 64 | expect(getRawBody).toHaveBeenCalledWith(req, { length: "10" }); 65 | }); 66 | }); 67 | 68 | describe("getBaseUrl", () => { 69 | it("should use x-forwarded-proto header for protocol", () => { 70 | const { req, res } = createMocks({ 71 | headers: { 72 | host: "example.com", 73 | "x-forwarded-proto": "https" 74 | } 75 | }); 76 | const adapter = new NextJsAdapter(req, res); 77 | expect(adapter.getBaseUrl()).toBe("https://example.com"); 78 | }); 79 | 80 | it("should prefer https when x-forwarded-proto has multiple values", () => { 81 | const { req, res } = createMocks({ 82 | headers: { 83 | host: "example.com", 84 | "x-forwarded-proto": "http,https,wss" 85 | } 86 | }); 87 | const adapter = new NextJsAdapter(req, res); 88 | expect(adapter.getBaseUrl()).toBe("https://example.com"); 89 | }); 90 | 91 | it("should prefer http when x-forwarded-proto has multiple values and https is not present", () => { 92 | const { req, res } = createMocks({ 93 | headers: { 94 | host: "example.com", 95 | "x-forwarded-proto": "wss,http" 96 | } 97 | }); 98 | const adapter = new NextJsAdapter(req, res); 99 | expect(adapter.getBaseUrl()).toBe("http://example.com"); 100 | }); 101 | 102 | it("should use first protocol when https is not present in x-forwarded-proto", () => { 103 | const { req, res } = createMocks({ 104 | headers: { 105 | host: "example.com", 106 | "x-forwarded-proto": "wss,ftp" 107 | } 108 | }); 109 | const adapter = new NextJsAdapter(req, res); 110 | expect(adapter.getBaseUrl()).toBe("wss://example.com"); 111 | }); 112 | }); 113 | 114 | describe("method", () => { 115 | it("should return POST method when used in request", () => { 116 | const { req, res } = createMocks({ 117 | method: "POST" 118 | }); 119 | const adapter = new NextJsAdapter(req, res); 120 | expect(adapter.method).toBe("POST"); 121 | }); 122 | 123 | it("should return GET method when used in request", () => { 124 | const { req, res } = createMocks({ 125 | method: "GET" 126 | }); 127 | const adapter = new NextJsAdapter(req, res); 128 | expect(adapter.method).toBe("GET"); 129 | }); 130 | }); 131 | }); 132 | 133 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/platform-adapter.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import getRawBody from "raw-body"; 3 | 4 | import { 5 | ActionHandlerResult, 6 | PlatformAdapterInterface, 7 | } from "@/handlers/shared/generic-adapter-use-case-types"; 8 | 9 | export type NextJsHandlerInput = NextApiRequest; 10 | export type NextJsHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; 11 | 12 | /** PlatformAdapter for Next.js /pages router API routes 13 | * 14 | * Platform adapters are used in Actions to handle generic request logic 15 | * like getting body, headers, etc. 16 | * 17 | * Thanks to this Actions logic can be re-used for each platform 18 | 19 | * @see {PlatformAdapterInterface} 20 | * @see {@link https://nextjs.org/docs/pages/building-your-application/routing/api-routes} 21 | * 22 | * */ 23 | export class NextJsAdapter implements PlatformAdapterInterface { 24 | readonly type = "next" as const; 25 | 26 | constructor(public request: NextApiRequest, private res: NextApiResponse) {} 27 | 28 | getHeader(name: string) { 29 | const header = this.request.headers[name]; 30 | return Array.isArray(header) ? header.join(", ") : header ?? null; 31 | } 32 | 33 | getBody(): Promise { 34 | return Promise.resolve(this.request.body); 35 | } 36 | 37 | async getRawBody(): Promise { 38 | return ( 39 | await getRawBody(this.request, { 40 | length: this.request.headers["content-length"], 41 | }) 42 | ).toString(); 43 | } 44 | 45 | getBaseUrl(): string { 46 | const { host, "x-forwarded-proto": xForwardedProto = "http" } = this.request.headers; 47 | 48 | const xForwardedProtos = Array.isArray(xForwardedProto) 49 | ? xForwardedProto.join(",") 50 | : xForwardedProto; 51 | const protocols = xForwardedProtos.split(","); 52 | // prefer https over other protocols 53 | const protocol = 54 | protocols.find((el) => el === "https") || 55 | protocols.find((el) => el === "http") || 56 | protocols[0]; 57 | 58 | return `${protocol}://${host}`; 59 | } 60 | 61 | get method() { 62 | return this.request.method as "POST" | "GET"; 63 | } 64 | 65 | async send(result: ActionHandlerResult): Promise { 66 | if (result.bodyType === "json") { 67 | this.res.status(result.status).json(result.body); 68 | } else { 69 | this.res.status(result.status).send(result.body); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | 3 | import { AsyncWebhookEventType } from "@/types"; 4 | 5 | import { NextJsWebhookHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; 6 | 7 | export class SaleorAsyncWebhook extends SaleorWebhook { 8 | readonly event: AsyncWebhookEventType; 9 | 10 | protected readonly eventType = "async" as const; 11 | 12 | constructor(configuration: WebhookConfig) { 13 | super(configuration); 14 | 15 | this.event = configuration.event; 16 | } 17 | 18 | createHandler(handlerFn: NextJsWebhookHandler): NextApiHandler { 19 | return super.createHandler(handlerFn); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from "next"; 2 | 3 | import { SyncWebhookEventType } from "@/types"; 4 | 5 | import { NextJsWebhookHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; 6 | 7 | export type NextJsSyncWebhookHandler = NextJsWebhookHandler; 8 | 9 | export class SaleorSyncWebhook< 10 | TPayload = unknown, 11 | TEvent extends SyncWebhookEventType = SyncWebhookEventType, 12 | > extends SaleorWebhook { 13 | readonly event: TEvent; 14 | 15 | protected readonly eventType = "sync" as const; 16 | 17 | constructor(configuration: WebhookConfig) { 18 | super(configuration); 19 | 20 | this.event = configuration.event; 21 | } 22 | 23 | createHandler(handlerFn: NextJsSyncWebhookHandler): NextApiHandler { 24 | return super.createHandler(handlerFn); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { createDebug } from "@/debug"; 4 | import { 5 | GenericSaleorWebhook, 6 | GenericWebhookConfig, 7 | } from "@/handlers/shared/generic-saleor-webhook"; 8 | import { WebhookContext } from "@/handlers/shared/saleor-webhook"; 9 | import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; 10 | 11 | import { NextJsAdapter } from "../platform-adapter"; 12 | 13 | const debug = createDebug("SaleorWebhook"); 14 | 15 | export type WebhookConfig = 16 | GenericWebhookConfig; 17 | 18 | export type NextJsWebhookHandler = ( 19 | req: NextApiRequest, 20 | res: NextApiResponse, 21 | ctx: WebhookContext, 22 | ) => unknown | Promise; 23 | 24 | export abstract class SaleorWebhook extends GenericSaleorWebhook< 25 | NextApiRequest, 26 | TPayload 27 | > { 28 | /** 29 | * Wraps provided function, to ensure incoming request comes from registered Saleor instance. 30 | * Also provides additional `context` object containing typed payload and request properties. 31 | */ 32 | createHandler(handlerFn: NextJsWebhookHandler): NextApiHandler { 33 | return async (req, res) => { 34 | const adapter = new NextJsAdapter(req, res); 35 | const prepareRequestResult = await super.prepareRequest(adapter); 36 | 37 | if (prepareRequestResult.result === "sendResponse") { 38 | return prepareRequestResult.response; 39 | } 40 | 41 | debug("Incoming request validated. Call handlerFn"); 42 | return handlerFn(req, res, { 43 | ...prepareRequestResult.context, 44 | }); 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/handlers/readme.md: -------------------------------------------------------------------------------- 1 | Place for Saleor-related API handlers - without frameworks 2 | -------------------------------------------------------------------------------- /src/handlers/shared/create-app-register-handler-types.ts: -------------------------------------------------------------------------------- 1 | import { AuthData } from "../../APL"; 2 | import { HasAPL } from "../../saleor-app"; 3 | 4 | export type HookCallbackErrorParams = { 5 | status?: number; 6 | message?: string; 7 | }; 8 | 9 | export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never; 10 | 11 | export type GenericCreateAppRegisterHandlerOptions = HasAPL & { 12 | /** 13 | * Protect app from being registered in Saleor other than specific. 14 | * By default, allow everything. 15 | * 16 | * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) 17 | * or a function that receives a full Saleor API URL ad returns true/false. 18 | */ 19 | allowedSaleorUrls?: Array boolean)>; 20 | /** 21 | * Run right after Saleor calls this endpoint 22 | */ 23 | onRequestStart?( 24 | request: RequestType, 25 | context: { 26 | authToken?: string; 27 | saleorApiUrl?: string; 28 | respondWithError: CallbackErrorHandler; 29 | } 30 | ): Promise; 31 | /** 32 | * Run after all security checks 33 | */ 34 | onRequestVerified?( 35 | request: RequestType, 36 | context: { 37 | authData: AuthData; 38 | respondWithError: CallbackErrorHandler; 39 | } 40 | ): Promise; 41 | /** 42 | * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error 43 | */ 44 | onAuthAplSaved?( 45 | request: RequestType, 46 | context: { 47 | authData: AuthData; 48 | respondWithError: CallbackErrorHandler; 49 | } 50 | ): Promise; 51 | /** 52 | * Run after APL fails to set AuthData 53 | */ 54 | onAplSetFailed?( 55 | request: RequestType, 56 | context: { 57 | authData: AuthData; 58 | error: unknown; 59 | respondWithError: CallbackErrorHandler; 60 | } 61 | ): Promise; 62 | }; 63 | -------------------------------------------------------------------------------- /src/handlers/shared/generic-adapter-use-case-types.ts: -------------------------------------------------------------------------------- 1 | export const HTTPMethod = { 2 | GET: "GET", 3 | POST: "POST", 4 | PUT: "PUT", 5 | PATH: "PATCH", 6 | HEAD: "HEAD", 7 | OPTIONS: "OPTIONS", 8 | DELETE: "DELETE", 9 | } as const; 10 | export type HTTPMethod = typeof HTTPMethod[keyof typeof HTTPMethod]; 11 | 12 | /** Status code of the result, for most platforms it's mapped to HTTP status code 13 | * however when request is not HTTP it can be mapped to something else */ 14 | export type ResultStatusCodes = number; 15 | 16 | /** Shape of result that should be returned from use case 17 | * that is then translated by adapter to a valid platform response */ 18 | export type ActionHandlerResult = 19 | | { 20 | status: ResultStatusCodes; 21 | body: Body; 22 | bodyType: "json"; 23 | } 24 | | { 25 | status: ResultStatusCodes; 26 | body: string; 27 | bodyType: "string"; 28 | }; 29 | 30 | /** 31 | * Interface for adapters that translate specific platform objects (e.g. Web API, Next.js) 32 | * into a common interface that can be used in each handler use case 33 | * */ 34 | export interface PlatformAdapterInterface { 35 | send(result: ActionHandlerResult): unknown; 36 | getHeader(name: string): string | null; 37 | getBody(): Promise; 38 | getRawBody(): Promise; 39 | getBaseUrl(): string; 40 | method: HTTPMethod; 41 | request: Request; 42 | } 43 | 44 | /** Interfaces for use case handlers that encapsulate business logic 45 | * (e.g. validating headers, checking HTTP method, etc. ) */ 46 | export interface ActionHandlerInterface { 47 | handleAction(...params: [unknown]): Promise>; 48 | } 49 | -------------------------------------------------------------------------------- /src/handlers/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-app-register-handler-types"; 2 | export * from "./generic-adapter-use-case-types"; 3 | export * from "./protected-handler"; 4 | export * from "./saleor-webhook"; 5 | export * from "./sync-webhook-response-builder"; 6 | -------------------------------------------------------------------------------- /src/handlers/shared/protected-handler.ts: -------------------------------------------------------------------------------- 1 | export type SaleorProtectedHandlerError = 2 | | "OTHER" 3 | | "MISSING_HOST_HEADER" 4 | | "MISSING_API_URL_HEADER" 5 | | "MISSING_AUTHORIZATION_BEARER_HEADER" 6 | | "NOT_REGISTERED" 7 | | "JWT_VERIFICATION_FAILED" 8 | | "NO_APP_ID"; 9 | 10 | export const ProtectedHandlerErrorCodeMap: Record = { 11 | OTHER: 500, 12 | MISSING_HOST_HEADER: 400, 13 | MISSING_API_URL_HEADER: 400, 14 | NOT_REGISTERED: 401, 15 | JWT_VERIFICATION_FAILED: 401, 16 | NO_APP_ID: 401, 17 | MISSING_AUTHORIZATION_BEARER_HEADER: 400, 18 | }; 19 | -------------------------------------------------------------------------------- /src/handlers/shared/saleor-request-processor.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { 4 | SALEOR_API_URL_HEADER, 5 | SALEOR_AUTHORIZATION_BEARER_HEADER, 6 | SALEOR_EVENT_HEADER, 7 | SALEOR_SCHEMA_VERSION_HEADER, 8 | SALEOR_SIGNATURE_HEADER, 9 | } from "@/headers"; 10 | import { MockAdapter } from "@/test-utils/mock-adapter"; 11 | 12 | import { SaleorRequestProcessor } from "./saleor-request-processor"; 13 | 14 | describe("SaleorRequestProcessor", () => { 15 | let mockAdapter: MockAdapter; 16 | 17 | beforeEach(() => { 18 | mockAdapter = new MockAdapter({}); 19 | }); 20 | 21 | describe("withMethod", () => { 22 | it("returns null when method is allowed", () => { 23 | mockAdapter.method = "POST"; 24 | const middleware = new SaleorRequestProcessor(mockAdapter); 25 | 26 | const result = middleware.withMethod(["POST", "GET"]); 27 | 28 | expect(result).toBeNull(); 29 | }); 30 | 31 | it("returns 405 error when method is not allowed", () => { 32 | mockAdapter.method = "POST"; 33 | const middleware = new SaleorRequestProcessor(mockAdapter); 34 | 35 | const result = middleware.withMethod(["GET"]); 36 | 37 | expect(result).toEqual({ 38 | body: "Method not allowed", 39 | bodyType: "string", 40 | status: 405, 41 | }); 42 | }); 43 | }); 44 | 45 | describe("withSaleorApiUrlPresent", () => { 46 | it("returns null when saleor-api-url header is present", () => { 47 | const adapter = new MockAdapter({ 48 | mockHeaders: { 49 | [SALEOR_API_URL_HEADER]: "https://api.saleor.io", 50 | }, 51 | }); 52 | const middleware = new SaleorRequestProcessor(adapter); 53 | 54 | const result = middleware.withSaleorApiUrlPresent(); 55 | 56 | expect(result).toBeNull(); 57 | }); 58 | 59 | it("returns 400 error when saleor api url is missing", () => { 60 | const middleware = new SaleorRequestProcessor(mockAdapter); 61 | 62 | const result = middleware.withSaleorApiUrlPresent(); 63 | 64 | expect(result).toEqual({ 65 | body: "Missing saleor-api-url header", 66 | bodyType: "string", 67 | status: 400, 68 | }); 69 | }); 70 | }); 71 | 72 | describe("getSaleorHeaders", () => { 73 | it("correctly transforms header values", () => { 74 | const adapter = new MockAdapter({ 75 | mockHeaders: { 76 | [SALEOR_AUTHORIZATION_BEARER_HEADER]: "bearer-token", 77 | [SALEOR_SIGNATURE_HEADER]: "signature-value", 78 | [SALEOR_EVENT_HEADER]: "event-name", 79 | [SALEOR_API_URL_HEADER]: "https://api.saleor.io", 80 | [SALEOR_SCHEMA_VERSION_HEADER]: "3.20", 81 | }, 82 | }); 83 | const middleware = new SaleorRequestProcessor(adapter); 84 | 85 | const result = middleware.getSaleorHeaders(); 86 | 87 | expect(result).toEqual({ 88 | authorizationBearer: "bearer-token", 89 | signature: "signature-value", 90 | event: "event-name", 91 | saleorApiUrl: "https://api.saleor.io", 92 | schemaVersion: "3.20", 93 | }); 94 | }); 95 | 96 | it("handles missing values correctly - returns undefined", () => { 97 | const middleware = new SaleorRequestProcessor(mockAdapter); 98 | 99 | const result = middleware.getSaleorHeaders(); 100 | 101 | expect(result).toEqual({ 102 | authorizationBearer: undefined, 103 | signature: undefined, 104 | event: undefined, 105 | saleorApiUrl: undefined, 106 | schemaVersion: undefined, 107 | }); 108 | }); 109 | 110 | it("handlers partially missing headers", () => { 111 | const adapter = new MockAdapter({ 112 | mockHeaders: { 113 | // SALEOR_AUTHORIZATION_BEARER_HEADER missing 114 | [SALEOR_SIGNATURE_HEADER]: "signature-value", 115 | [SALEOR_EVENT_HEADER]: "event-name", 116 | [SALEOR_API_URL_HEADER]: "https://api.saleor.io", 117 | // SALEOR_SCHEMA_VERSION missing 118 | }, 119 | }); 120 | const middleware = new SaleorRequestProcessor(adapter); 121 | 122 | const result = middleware.getSaleorHeaders(); 123 | 124 | expect(result).toEqual({ 125 | authorizationBearer: undefined, 126 | signature: "signature-value", 127 | event: "event-name", 128 | saleorApiUrl: "https://api.saleor.io", 129 | schemaVersion: undefined, 130 | }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/handlers/shared/saleor-request-processor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SALEOR_API_URL_HEADER, 3 | SALEOR_AUTHORIZATION_BEARER_HEADER, 4 | SALEOR_EVENT_HEADER, 5 | SALEOR_SCHEMA_VERSION_HEADER, 6 | SALEOR_SIGNATURE_HEADER, 7 | } from "@/headers"; 8 | 9 | import { HTTPMethod, PlatformAdapterInterface } from "./generic-adapter-use-case-types"; 10 | 11 | export class SaleorRequestProcessor { 12 | constructor(private adapter: PlatformAdapterInterface) {} 13 | 14 | withMethod(methods: HTTPMethod[]) { 15 | if (!methods.includes(this.adapter.method)) { 16 | return { 17 | body: "Method not allowed", 18 | bodyType: "string", 19 | status: 405, 20 | } as const; 21 | } 22 | 23 | return null; 24 | } 25 | 26 | withSaleorApiUrlPresent() { 27 | const { saleorApiUrl } = this.getSaleorHeaders(); 28 | 29 | if (!saleorApiUrl) { 30 | return { 31 | body: "Missing saleor-api-url header", 32 | bodyType: "string", 33 | status: 400, 34 | } as const; 35 | } 36 | 37 | return null; 38 | } 39 | 40 | private toStringOrUndefined = (value: string | string[] | undefined | null) => 41 | value ? value.toString() : undefined; 42 | 43 | getSaleorHeaders() { 44 | return { 45 | authorizationBearer: this.toStringOrUndefined( 46 | this.adapter.getHeader(SALEOR_AUTHORIZATION_BEARER_HEADER), 47 | ), 48 | signature: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SIGNATURE_HEADER)), 49 | event: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_EVENT_HEADER)), 50 | saleorApiUrl: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_API_URL_HEADER)), 51 | /** 52 | * Schema version must remain string. Since format is "x.x" like "3.20" for javascript it's a floating numer - so it's 3.2 53 | * Saleor version 3.20 != 3.2. 54 | * Semver must be compared as strings 55 | */ 56 | schemaVersion: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SCHEMA_VERSION_HEADER)), 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/handlers/shared/saleor-webhook.ts: -------------------------------------------------------------------------------- 1 | import { SaleorSchemaVersion } from "@/types"; 2 | 3 | import { AuthData } from "../../APL"; 4 | 5 | export const WebhookErrorCodeMap: Record = { 6 | OTHER: 500, 7 | MISSING_HOST_HEADER: 400, 8 | MISSING_DOMAIN_HEADER: 400, 9 | MISSING_API_URL_HEADER: 400, 10 | MISSING_EVENT_HEADER: 400, 11 | MISSING_PAYLOAD_HEADER: 400, 12 | MISSING_SIGNATURE_HEADER: 400, 13 | MISSING_REQUEST_BODY: 400, 14 | WRONG_EVENT: 400, 15 | NOT_REGISTERED: 401, 16 | SIGNATURE_VERIFICATION_FAILED: 401, 17 | WRONG_METHOD: 405, 18 | CANT_BE_PARSED: 400, 19 | CONFIGURATION_ERROR: 500, 20 | }; 21 | 22 | export type SaleorWebhookError = 23 | | "OTHER" 24 | | "MISSING_HOST_HEADER" 25 | | "MISSING_DOMAIN_HEADER" 26 | | "MISSING_API_URL_HEADER" 27 | | "MISSING_EVENT_HEADER" 28 | | "MISSING_PAYLOAD_HEADER" 29 | | "MISSING_SIGNATURE_HEADER" 30 | | "MISSING_REQUEST_BODY" 31 | | "WRONG_EVENT" 32 | | "NOT_REGISTERED" 33 | | "SIGNATURE_VERIFICATION_FAILED" 34 | | "WRONG_METHOD" 35 | | "CANT_BE_PARSED" 36 | | "CONFIGURATION_ERROR"; 37 | 38 | export class WebhookError extends Error { 39 | errorType: SaleorWebhookError = "OTHER"; 40 | 41 | constructor(message: string, errorType: SaleorWebhookError) { 42 | super(message); 43 | if (errorType) { 44 | this.errorType = errorType; 45 | } 46 | Object.setPrototypeOf(this, WebhookError.prototype); 47 | } 48 | } 49 | 50 | export type WebhookContext = { 51 | baseUrl: string; 52 | event: string; 53 | payload: TPayload; 54 | authData: AuthData; 55 | /** 56 | * Schema version is passed in subscription payload. Webhook must request it, otherwise it will be null. 57 | * If subscription contains version, it will be parsed to SaleorSchemaVersion 58 | */ 59 | schemaVersion: SaleorSchemaVersion | null; 60 | }; 61 | 62 | export type FormatWebhookErrorResult = { 63 | code: number; 64 | body: string; 65 | }; 66 | -------------------------------------------------------------------------------- /src/handlers/shared/validate-allow-saleor-urls.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { validateAllowSaleorUrls } from "./validate-allow-saleor-urls"; 4 | 5 | const saleorCloudUrlMock = "https://my-shop.saleor.cloud/graphql/"; 6 | const onPremiseSaleorUrlMock = "https://my-shop-123.aws-services.com/graphql/"; 7 | 8 | const saleorCloudRegexValidator = (url: string) => /https:\/\/.*.saleor.cloud\/graphql\//.test(url); 9 | 10 | describe("validateAllowSaleorUrls", () => { 11 | it("Passes any URL if allow list is empty", () => { 12 | expect(validateAllowSaleorUrls(saleorCloudUrlMock, [])).toBe(true); 13 | expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [])).toBe(true); 14 | }); 15 | 16 | it("Passes only for URL that was exactly matched in provided allow list array", () => { 17 | expect(validateAllowSaleorUrls(saleorCloudUrlMock, [saleorCloudUrlMock])).toBe(true); 18 | expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [saleorCloudUrlMock])).toBe(false); 19 | }); 20 | 21 | it("Validates against custom function provided to allow list", () => { 22 | expect(validateAllowSaleorUrls(saleorCloudUrlMock, [saleorCloudRegexValidator])).toBe(true); 23 | expect(validateAllowSaleorUrls(onPremiseSaleorUrlMock, [saleorCloudRegexValidator])).toBe( 24 | false 25 | ); 26 | }); 27 | 28 | it("Validates against more than one argument in allow list", () => { 29 | expect( 30 | validateAllowSaleorUrls(saleorCloudUrlMock, [ 31 | saleorCloudRegexValidator, 32 | onPremiseSaleorUrlMock, 33 | ]) 34 | ).toBe(true); 35 | expect( 36 | validateAllowSaleorUrls(onPremiseSaleorUrlMock, [ 37 | saleorCloudRegexValidator, 38 | onPremiseSaleorUrlMock, 39 | ]) 40 | ).toBe(true); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/handlers/shared/validate-allow-saleor-urls.ts: -------------------------------------------------------------------------------- 1 | import { CreateAppRegisterHandlerOptions } from "../platforms/next/create-app-register-handler"; 2 | 3 | export const validateAllowSaleorUrls = ( 4 | saleorApiUrl: string, 5 | allowedUrls: CreateAppRegisterHandlerOptions["allowedSaleorUrls"] 6 | ) => { 7 | if (!allowedUrls || allowedUrls.length === 0) { 8 | return true; 9 | } 10 | 11 | for (const urlOrFn of allowedUrls) { 12 | if (typeof urlOrFn === "string" && urlOrFn === saleorApiUrl) { 13 | return true; 14 | } 15 | 16 | if (typeof urlOrFn === "function" && urlOrFn(saleorApiUrl)) { 17 | return true; 18 | } 19 | } 20 | 21 | return false; 22 | }; 23 | -------------------------------------------------------------------------------- /src/has-prop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safely narrow unknown object and infer property existence 3 | * @param obj 4 | * @param key 5 | */ 6 | export function hasProp( 7 | obj: unknown, 8 | key: K | null | undefined 9 | ): obj is Record { 10 | return key != null && obj != null && typeof obj === "object" && key in obj; 11 | } 12 | -------------------------------------------------------------------------------- /src/headers.ts: -------------------------------------------------------------------------------- 1 | export const SALEOR_EVENT_HEADER = "saleor-event"; 2 | export const SALEOR_SIGNATURE_HEADER = "saleor-signature"; 3 | export const SALEOR_AUTHORIZATION_BEARER_HEADER = "authorization-bearer"; 4 | export const SALEOR_API_URL_HEADER = "saleor-api-url"; 5 | /** 6 | * Available when Saleor executes "manifest" or "token exchange" requests. 7 | */ 8 | export const SALEOR_SCHEMA_VERSION_HEADER = "saleor-schema-version"; 9 | 10 | const toStringOrUndefined = (value: string | string[] | undefined) => 11 | value ? value.toString() : undefined; 12 | 13 | /** 14 | * Extracts Saleor-specific headers from the response. 15 | */ 16 | export const getSaleorHeaders = (headers: { [name: string]: string | string[] | undefined }) => ({ 17 | authorizationBearer: toStringOrUndefined(headers[SALEOR_AUTHORIZATION_BEARER_HEADER]), 18 | signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]), 19 | event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]), 20 | saleorApiUrl: toStringOrUndefined(headers[SALEOR_API_URL_HEADER]), 21 | schemaVersion: toStringOrUndefined(headers[SALEOR_SCHEMA_VERSION_HEADER]), 22 | }); 23 | 24 | /** 25 | * Extracts the app's url from headers from the response. 26 | */ 27 | export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { 28 | const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers; 29 | 30 | const xForwardedProtos = Array.isArray(xForwardedProto) 31 | ? xForwardedProto.join(",") 32 | : xForwardedProto; 33 | const protocols = xForwardedProtos.split(","); 34 | // prefer https over other protocols 35 | const protocol = protocols.find((el) => el === "https") || protocols[0]; 36 | 37 | return `${protocol}://${host}`; 38 | }; 39 | -------------------------------------------------------------------------------- /src/locales.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Available locales in Saleor Dashboard. 3 | * TODO: Extract to shared package between Dashboard and sdk, so this stay in sync? 4 | */ 5 | export type LocaleCode = 6 | | "ar" 7 | | "az" 8 | | "bg" 9 | | "bn" 10 | | "ca" 11 | | "cs" 12 | | "da" 13 | | "de" 14 | | "el" 15 | | "en" 16 | | "es" 17 | | "es-CO" 18 | | "et" 19 | | "fa" 20 | | "fr" 21 | | "hi" 22 | | "hu" 23 | | "hy" 24 | | "id" 25 | | "is" 26 | | "it" 27 | | "ja" 28 | | "ko" 29 | | "mn" 30 | | "nb" 31 | | "nl" 32 | | "pl" 33 | | "pt" 34 | | "pt-BR" 35 | | "ro" 36 | | "ru" 37 | | "sk" 38 | | "sl" 39 | | "sq" 40 | | "sr" 41 | | "sv" 42 | | "th" 43 | | "tr" 44 | | "uk" 45 | | "vi" 46 | | "zh-Hans" 47 | | "zh-Hant"; 48 | -------------------------------------------------------------------------------- /src/open-telemetry.ts: -------------------------------------------------------------------------------- 1 | import { trace, Tracer } from "@opentelemetry/api"; 2 | 3 | import pkg from "../package.json"; 4 | 5 | const TRACER_NAME = "app-sdk"; 6 | 7 | export const getOtelTracer = (): Tracer => trace.getTracer(TRACER_NAME, pkg.version); 8 | 9 | export const OTEL_CORE_SERVICE_NAME = "core"; 10 | export const OTEL_APL_SERVICE_NAME = "apps-cloud-apl"; 11 | -------------------------------------------------------------------------------- /src/saleor-app.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { FileAPL } from "@/APL/file"; 4 | 5 | import { SaleorApp } from "./saleor-app"; 6 | 7 | describe("SaleorApp", () => { 8 | const initialEnv = { ...process.env }; 9 | 10 | afterEach(() => { 11 | process.env = { ...initialEnv }; 12 | vi.resetModules(); 13 | }); 14 | 15 | it("Constructs", () => { 16 | const instance = new SaleorApp({ 17 | apl: new FileAPL(), 18 | }); 19 | 20 | expect(instance).toBeDefined(); 21 | expect(instance.apl).toBeInstanceOf(FileAPL); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/saleor-app.ts: -------------------------------------------------------------------------------- 1 | import { APL } from "./APL"; 2 | 3 | export interface HasAPL { 4 | apl: APL; 5 | } 6 | 7 | export interface SaleorAppParams { 8 | apl: APL; 9 | } 10 | 11 | export class SaleorApp implements HasAPL { 12 | readonly apl: APL; 13 | 14 | constructor(options: SaleorAppParams) { 15 | this.apl = options.apl; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/settings-manager/encrypted-metadata-manager.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | import { MetadataManager, MetadataManagerConfig } from "./metadata-manager"; 4 | import { DeleteSettingsValue, SettingsManager, SettingsValue } from "./settings-manager"; 5 | 6 | export type EncryptCallback = (value: string, secret: string) => string; 7 | 8 | export type DecryptCallback = (value: string, secret: string) => string; 9 | 10 | /** 11 | * Ensures key has constant length of 32 characters 12 | */ 13 | const prepareKey = (key: string) => 14 | crypto.createHash("sha256").update(String(key)).digest("base64").substr(0, 32); 15 | 16 | /** 17 | * Encrypt string using AES-256 18 | */ 19 | export const encrypt = (data: string, key: string) => { 20 | const iv = crypto.randomBytes(16).toString("hex").slice(0, 16); 21 | const cipher = crypto.createCipheriv("aes256", prepareKey(key), iv); 22 | let encrypted = cipher.update(data, "utf8", "hex"); 23 | encrypted += cipher.final("hex"); 24 | 25 | return `${iv}${encrypted}`; 26 | }; 27 | 28 | /** 29 | * Decrypt string encrypted with AES-256 30 | */ 31 | export const decrypt = (data: string, key: string) => { 32 | const [iv, encrypted] = [data.slice(0, 16), data.slice(16)]; 33 | const decipher = crypto.createDecipheriv("aes256", prepareKey(key), iv); 34 | let message = decipher.update(encrypted, "hex", "utf8"); 35 | message += decipher.final("utf8"); 36 | 37 | return message; 38 | }; 39 | 40 | interface EncryptedMetadataManagerConfig extends MetadataManagerConfig { 41 | encryptionKey: string; 42 | encryptionMethod?: EncryptCallback; 43 | decryptionMethod?: DecryptCallback; 44 | } 45 | 46 | /** 47 | * Encrypted Metadata Manager use app metadata to store settings. 48 | * To minimize network calls, once fetched metadata are cached. 49 | * Cache invalidation occurs if any value is set. 50 | * 51 | * By default data encryption use AES-256 algorithm. If you want to use a different 52 | * method, provide `encryptionMethod` and `decryptionMethod`. 53 | */ 54 | export class EncryptedMetadataManager implements SettingsManager { 55 | private encryptionKey: string; 56 | 57 | private encryptionMethod: EncryptCallback; 58 | 59 | private decryptionMethod: DecryptCallback; 60 | 61 | private metadataManager: MetadataManager; 62 | 63 | constructor({ 64 | fetchMetadata, 65 | mutateMetadata, 66 | encryptionKey, 67 | encryptionMethod, 68 | decryptionMethod, 69 | deleteMetadata, 70 | }: EncryptedMetadataManagerConfig) { 71 | this.metadataManager = new MetadataManager({ 72 | fetchMetadata, 73 | mutateMetadata, 74 | deleteMetadata, 75 | }); 76 | if (encryptionKey) { 77 | this.encryptionKey = encryptionKey; 78 | } else { 79 | console.warn("Encrypted Metadata Manager secret key has not been set."); 80 | if (process.env.NODE_ENV === "production") { 81 | console.error("Can't start the application without the secret key."); 82 | throw new Error( 83 | "Encryption key for the EncryptedMetadataManager has not been set. Setting it for the production environments is necessary. You can find more in the documentation: https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-apps/app-sdk/settings-manager", 84 | ); 85 | } 86 | console.warn( 87 | "WARNING: Encrypted Metadata Manager encryption key has not been set. For production deployments, it need's to be set", 88 | ); 89 | console.warn("Using placeholder value for the development."); 90 | this.encryptionKey = "CHANGE_ME"; 91 | } 92 | this.encryptionMethod = encryptionMethod || encrypt; 93 | this.decryptionMethod = decryptionMethod || decrypt; 94 | } 95 | 96 | async get(key: string, domain?: string) { 97 | const encryptedValue = await this.metadataManager.get(key, domain); 98 | if (!encryptedValue) { 99 | return undefined; 100 | } 101 | return this.decryptionMethod(encryptedValue, this.encryptionKey); 102 | } 103 | 104 | async set(settings: SettingsValue[] | SettingsValue) { 105 | if (!Array.isArray(settings)) { 106 | const encryptedValue = this.encryptionMethod(settings.value, this.encryptionKey); 107 | return this.metadataManager.set({ ...settings, value: encryptedValue }); 108 | } 109 | const encryptedSettings = settings.map((s) => ({ 110 | ...s, 111 | value: this.encryptionMethod(s.value, this.encryptionKey), 112 | })); 113 | return this.metadataManager.set(encryptedSettings); 114 | } 115 | 116 | async delete(args: DeleteSettingsValue | DeleteSettingsValue[] | string | string[]) { 117 | await this.metadataManager.delete(args); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/settings-manager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./encrypted-metadata-manager"; 2 | export * from "./metadata-manager"; 3 | export * from "./settings-manager"; 4 | -------------------------------------------------------------------------------- /src/settings-manager/metadata-manager.ts: -------------------------------------------------------------------------------- 1 | import { DeleteSettingsValue, SettingsManager, SettingsValue } from "./settings-manager"; 2 | 3 | export type MetadataEntry = { 4 | key: string; 5 | value: string; 6 | }; 7 | 8 | /** 9 | * TODO Rename "Callback" suffixes, these are not callbacks 10 | */ 11 | export type FetchMetadataCallback = () => Promise; 12 | export type MutateMetadataCallback = (metadata: MetadataEntry[]) => Promise; 13 | export type DeleteMetadataCallback = (keys: string[]) => Promise; 14 | 15 | const deserializeMetadata = ({ key, value }: MetadataEntry): SettingsValue => { 16 | // domain specific metadata use convention key__domain, e.g. `secret_key__example.com` 17 | const [newKey, domain] = key.split("__"); 18 | 19 | return { 20 | key: newKey, 21 | domain, 22 | value, 23 | }; 24 | }; 25 | 26 | const constructDomainSpecificKey = (key: string, saleorApiUrl: string) => 27 | [key, saleorApiUrl].join("__"); 28 | 29 | const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): MetadataEntry => { 30 | // domain specific metadata use convention key__domain, e.g. `secret_key__example.com` 31 | if (!domain) { 32 | return { key, value }; 33 | } 34 | 35 | return { 36 | key: constructDomainSpecificKey(key, domain), 37 | value, 38 | }; 39 | }; 40 | 41 | export interface MetadataManagerConfig { 42 | fetchMetadata: FetchMetadataCallback; 43 | mutateMetadata: MutateMetadataCallback; 44 | deleteMetadata: DeleteMetadataCallback; 45 | } 46 | 47 | /** 48 | * Metadata Manager use app metadata to store settings. 49 | * To minimize network calls, once fetched metadata are cached. 50 | * Cache invalidation occurs if any value is set. 51 | * 52 | * 53 | */ 54 | export class MetadataManager implements SettingsManager { 55 | private settings: SettingsValue[] = []; 56 | 57 | private fetchMetadata: FetchMetadataCallback; 58 | 59 | private mutateMetadata: MutateMetadataCallback; 60 | 61 | private deleteMetadata: DeleteMetadataCallback; 62 | 63 | constructor({ fetchMetadata, mutateMetadata, deleteMetadata }: MetadataManagerConfig) { 64 | this.fetchMetadata = fetchMetadata; 65 | this.mutateMetadata = mutateMetadata; 66 | this.deleteMetadata = deleteMetadata; 67 | } 68 | 69 | async get(key: string, domain?: string) { 70 | if (!this.settings.length) { 71 | const metadata = await this.fetchMetadata(); 72 | this.settings = metadata.map(deserializeMetadata); 73 | } 74 | 75 | const setting = this.settings.find((md) => md.key === key && md.domain === domain); 76 | return setting?.value; 77 | } 78 | 79 | async set(settings: SettingsValue[] | SettingsValue) { 80 | let serializedMetadata = []; 81 | if (Array.isArray(settings)) { 82 | serializedMetadata = settings.map(serializeSettingsToMetadata); 83 | } else { 84 | serializedMetadata = [serializeSettingsToMetadata(settings)]; 85 | } 86 | // changes should update cache 87 | const metadata = await this.mutateMetadata(serializedMetadata); 88 | this.settings = metadata.map(deserializeMetadata); 89 | } 90 | 91 | /** 92 | * Typescript doesn't properly infer arguments, so they have to be rewritten explicitly 93 | */ 94 | async delete(args: DeleteSettingsValue | DeleteSettingsValue[] | string | string[]) { 95 | if (!this.deleteMetadata) { 96 | throw new Error( 97 | "Delete not implemented. Ensure MetadataManager is configured with deleteMetadata param in constructor", 98 | ); 99 | } 100 | 101 | const argsArray = Array.isArray(args) ? args : [args]; 102 | const keysToDelete = argsArray.map((keyOrDomainPair) => { 103 | if (typeof keyOrDomainPair === "string") { 104 | return keyOrDomainPair; 105 | } 106 | const { key, domain } = keyOrDomainPair; 107 | return constructDomainSpecificKey(key, domain); 108 | }); 109 | 110 | await this.deleteMetadata(keysToDelete); 111 | 112 | this.settings = this.settings.filter((setting) => !argsArray.includes(setting.key)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/settings-manager/settings-manager.ts: -------------------------------------------------------------------------------- 1 | export type SettingsValue = { 2 | key: string; 3 | value: string; 4 | domain?: string; 5 | }; 6 | 7 | export type DeleteSettingsValue = { 8 | key: string; 9 | domain: string; 10 | }; 11 | 12 | export interface SettingsManager { 13 | get: (key: string, domain?: string) => Promise; 14 | set: (settings: SettingsValue[] | SettingsValue) => Promise; 15 | delete: (args: DeleteSettingsValue | DeleteSettingsValue[] | string | string[]) => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/setup-tests.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | /** 4 | * Emulate browser/web crypto - in tests it doesn't exist. 5 | * In a runtime global crypto is defined 6 | */ 7 | Object.defineProperty(global, "crypto", { 8 | value: { 9 | randomUUID: () => crypto.randomUUID(), 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/test-utils/mock-adapter.ts: -------------------------------------------------------------------------------- 1 | import { HTTPMethod, PlatformAdapterInterface } from "@/handlers/shared/generic-adapter-use-case-types"; 2 | 3 | export class MockAdapter implements PlatformAdapterInterface { 4 | constructor(public config: { mockHeaders?: Record; baseUrl?: string }) { 5 | } 6 | 7 | send() { 8 | throw new Error("Method not implemented."); 9 | } 10 | 11 | getHeader(key: string) { 12 | if (this.config.mockHeaders && key in this.config.mockHeaders) { 13 | return this.config.mockHeaders[key]; 14 | } 15 | return null; 16 | } 17 | 18 | async getBody(): Promise { 19 | return null; 20 | } 21 | 22 | async getRawBody(): Promise { 23 | return "{}"; 24 | } 25 | 26 | getBaseUrl() { 27 | if (this.config.baseUrl) { 28 | return this.config.baseUrl; 29 | } 30 | return ""; 31 | } 32 | 33 | method: HTTPMethod = "POST"; 34 | 35 | request = {}; 36 | } 37 | -------------------------------------------------------------------------------- /src/test-utils/mock-apl.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | import { APL, AuthData } from "../APL"; 4 | 5 | type Options = { 6 | workForApiUrl?: string; 7 | savedAllAuthData?: AuthData[]; 8 | }; 9 | 10 | /** 11 | * Test utility used across scenarios to simulate various APL behaviors 12 | */ 13 | export class MockAPL implements APL { 14 | private readonly options: Options = { 15 | workForApiUrl: "https://example.com/graphql/", 16 | savedAllAuthData: [], 17 | }; 18 | 19 | constructor(opts?: Options) { 20 | this.options = { 21 | ...this.options, 22 | ...(opts ?? {}), 23 | }; 24 | 25 | this.workingSaleorApiUrl = this.options.workForApiUrl ?? this.workingSaleorApiUrl; 26 | } 27 | 28 | mockJwks = "{}"; 29 | 30 | mockToken = "mock-token"; 31 | 32 | mockAppId = "mock-app-id"; 33 | 34 | workingSaleorApiUrl = "https://example.com/graphql/"; 35 | 36 | resolveDomainFromApiUrl = (apiUrl: string) => 37 | apiUrl.replace("/graphql/", "").replace("https://", ""); 38 | 39 | get workingSaleorDomain() { 40 | return this.resolveDomainFromApiUrl(this.workingSaleorApiUrl); 41 | } 42 | 43 | async get(saleorApiUrl: string): Promise { 44 | if (saleorApiUrl === this.workingSaleorApiUrl) { 45 | return { 46 | token: this.mockToken, 47 | saleorApiUrl, 48 | appId: this.mockAppId, 49 | jwks: this.mockJwks, 50 | }; 51 | } 52 | 53 | return undefined; 54 | } 55 | 56 | set = vi.fn(); 57 | 58 | delete = vi.fn(); 59 | 60 | getAll = vi.fn().mockImplementation(async () => this.options.savedAllAuthData); 61 | 62 | isReady = vi.fn().mockImplementation(async () => ({ 63 | ready: true, 64 | })); 65 | 66 | isConfigured = vi.fn().mockImplementation(async () => ({ 67 | configured: true, 68 | })); 69 | } 70 | -------------------------------------------------------------------------------- /src/util/extract-app-permissions-from-jwt.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { extractAppPermissionsFromJwt } from "./extract-app-permissions-from-jwt"; 4 | 5 | /** 6 | * Contains 7 | * "permissions": [ 8 | "MANAGE_ORDERS", 9 | "HANDLE_TAXES", 10 | "MANAGE_CHANNELS" 11 | ], 12 | 13 | https://jwt.io/ 14 | */ 15 | const jwtWithPermissions = 16 | "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2ODk4NTIyOTEsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiaHR0cHM6Ly9oYWNrYXRob24tc2hpcHBpbmcuZXUuc2FsZW9yLmNsb3VkL2dyYXBocWwvIiwiZXhwIjoxNjg5OTM4NjkxLCJ0b2tlbiI6IjNHTWRUSVpab3FRSSIsImVtYWlsIjoibHVrYXN6Lm9zdHJvd3NraUBzYWxlb3IuaW8iLCJ0eXBlIjoidGhpcmRwYXJ0eSIsInVzZXJfaWQiOiJWWE5sY2pveU1nPT0iLCJpc19zdGFmZiI6dHJ1ZSwiYXBwIjoiUVhCd09qSXdNalE9IiwicGVybWlzc2lvbnMiOlsiTUFOQUdFX09SREVSUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9DSEFOTkVMUyJdLCJ1c2VyX3Blcm1pc3Npb25zIjpbIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9TRVRUSU5HUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9QQUdFUyIsIkhBTkRMRV9DSEVDS09VVFMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfVFJBTlNMQVRJT05TIiwiTUFOQUdFX1BST0RVQ1RTIiwiTUFOQUdFX1RBWEVTIiwiTUFOQUdFX09CU0VSVkFCSUxJVFkiLCJNQU5BR0VfT1JERVJTX0lNUE9SVCIsIk1BTkFHRV9DSEFOTkVMUyIsIk1BTkFHRV9BUFBTIiwiSU1QRVJTT05BVEVfVVNFUiIsIk1BTkFHRV9QUk9EVUNUX1RZUEVTX0FORF9BVFRSSUJVVEVTIiwiSEFORExFX1BBWU1FTlRTIiwiTUFOQUdFX0NIRUNLT1VUUyIsIk1BTkFHRV9HSUZUX0NBUkQiLCJNQU5BR0VfU0hJUFBJTkciLCJNQU5BR0VfU1RBRkYiLCJNQU5BR0VfRElTQ09VTlRTIiwiTUFOQUdFX1BMVUdJTlMiLCJNQU5BR0VfT1JERVJTIiwiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiXX0.zGglCWxuOBgGJKyyZ-6m9Th4_tGUMCMjF6W3UQhaTl5P_tQ2694Pcjwnr2zDzeF0Hl4J-gPWlyH4fLnfHIaJpDds9POtZv1D-bE5kChtkcUC1hfBUzb7iL8SwtQhtvSWy-XmsVDpQTMeD7q5McRSaKNPf3IzPXPJx-F_y5OGpgTukXoweVOufG7jcyrKWyePTqJn1evQTawQOYlzp3nj22uE4sn4UQvpbPgHIbcPohoJSdKigwAPaUqTIz_q8Mrpn4EBUezrs0_24E49kILt4K6Otupbba7rJxQe5664-o7FnSunp-2gtr6zdUaY5hV3bR84WjQZFtgCOgPVd_YT9Q"; 17 | 18 | describe("extractAppPermissionsFromJwt", () => { 19 | it("Returns permissions field from JWT token as an array of AppPermission", () => { 20 | const permissions = extractAppPermissionsFromJwt(jwtWithPermissions); 21 | 22 | expect(permissions).toEqual(["MANAGE_ORDERS", "HANDLE_TAXES", "MANAGE_CHANNELS"]); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/util/extract-app-permissions-from-jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | 3 | import { AppPermission } from "../types"; 4 | 5 | export const extractAppPermissionsFromJwt = (jwtToken: string): AppPermission[] => { 6 | const tokenDecoded = jose.decodeJwt(jwtToken); 7 | 8 | return tokenDecoded.permissions as AppPermission[]; 9 | }; 10 | -------------------------------------------------------------------------------- /src/util/extract-user-from-jwt.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { extractUserFromJwt } from "./extract-user-from-jwt"; 4 | 5 | const validJwtToken = 6 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; 7 | 8 | const invalidToken = "foo"; 9 | 10 | describe("extractUserFromJwt", () => { 11 | it("Throws if token is invalid", () => { 12 | expect(() => extractUserFromJwt(invalidToken)).toThrow(); 13 | }); 14 | 15 | it("Extracts email and user permissions from the token", () => { 16 | expect(extractUserFromJwt(validJwtToken)).toEqual({ 17 | email: "admin@example.com", 18 | userPermissions: [ 19 | "MANAGE_PAGE_TYPES_AND_ATTRIBUTES", 20 | "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES", 21 | "MANAGE_DISCOUNTS", 22 | "MANAGE_PLUGINS", 23 | "MANAGE_STAFF", 24 | "MANAGE_PRODUCTS", 25 | "MANAGE_SHIPPING", 26 | "MANAGE_TRANSLATIONS", 27 | "MANAGE_OBSERVABILITY", 28 | "MANAGE_USERS", 29 | "MANAGE_APPS", 30 | "MANAGE_CHANNELS", 31 | "MANAGE_GIFT_CARD", 32 | "HANDLE_PAYMENTS", 33 | "IMPERSONATE_USER", 34 | "MANAGE_SETTINGS", 35 | "MANAGE_PAGES", 36 | "MANAGE_MENUS", 37 | "MANAGE_CHECKOUTS", 38 | "HANDLE_CHECKOUTS", 39 | "MANAGE_ORDERS", 40 | ], 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/util/extract-user-from-jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | 3 | import { Permission } from "../types"; 4 | 5 | export type TokenUserPayload = { 6 | email: string; 7 | userPermissions: Permission[]; 8 | }; 9 | 10 | export const extractUserFromJwt = (jwtToken: string): TokenUserPayload => { 11 | const tokenDecoded = jose.decodeJwt(jwtToken); 12 | 13 | const { email, user_permissions: userPermissions } = tokenDecoded; 14 | 15 | return { 16 | email, 17 | userPermissions, 18 | } as TokenUserPayload; 19 | }; 20 | -------------------------------------------------------------------------------- /src/util/is-in-iframe.ts: -------------------------------------------------------------------------------- 1 | export const isInIframe = () => { 2 | if (!document || !window) { 3 | throw new Error("isInIframe should be called only in browser"); 4 | } 5 | 6 | try { 7 | return document.location !== window.parent.location; 8 | } catch (e) { 9 | return false; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/public/browser/index.ts: -------------------------------------------------------------------------------- 1 | export * from "../../is-in-iframe"; 2 | export * from "../../use-is-mounted"; 3 | -------------------------------------------------------------------------------- /src/util/public/index.ts: -------------------------------------------------------------------------------- 1 | export * from "../schema-version"; 2 | -------------------------------------------------------------------------------- /src/util/schema-version.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { parseSchemaVersion } from "./schema-version"; 4 | 5 | describe("parseSchemaVersion", () => { 6 | test.each([ 7 | { 8 | rawVersion: "3", 9 | parsedVersion: null, 10 | }, 11 | { 12 | rawVersion: "3.19", 13 | parsedVersion: [3, 19], 14 | }, 15 | { 16 | rawVersion: "3.19.1", 17 | parsedVersion: [3, 19], 18 | }, 19 | { 20 | rawVersion: "malformed", 21 | parsedVersion: null, 22 | }, 23 | { 24 | rawVersion: "malformed.raw", 25 | parsedVersion: null, 26 | }, 27 | { 28 | rawVersion: "malformed.raw.version", 29 | parsedVersion: null, 30 | }, 31 | { 32 | rawVersion: null, 33 | parsedVersion: null, 34 | }, 35 | { 36 | rawVersion: undefined, 37 | parsedVersion: null, 38 | }, 39 | ])( 40 | "Parses version string from: $rawVersion to: $parsedVersion", 41 | ({ rawVersion, parsedVersion }) => { 42 | expect(parseSchemaVersion(rawVersion)).toEqual(parsedVersion); 43 | }, 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /src/util/schema-version.ts: -------------------------------------------------------------------------------- 1 | import { SaleorSchemaVersion } from "@/types"; 2 | 3 | export const parseSchemaVersion = ( 4 | rawVersion: string | undefined | null, 5 | ): SaleorSchemaVersion | null => { 6 | if (!rawVersion) { 7 | return null; 8 | } 9 | 10 | const [majorString, minorString] = rawVersion.split("."); 11 | const major = parseInt(majorString, 10); 12 | const minor = parseInt(minorString, 10); 13 | 14 | if (major && minor) { 15 | return [major, minor]; 16 | } 17 | 18 | return null; 19 | }; 20 | -------------------------------------------------------------------------------- /src/util/use-is-mounted.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsMounted = (): boolean => { 4 | const [mounted, setMounted] = useState(false); 5 | useEffect(() => setMounted(true), []); 6 | 7 | return mounted; 8 | }; 9 | -------------------------------------------------------------------------------- /test/integration/redis-apl.test.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "redis"; 2 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; 3 | 4 | import { RedisAPL } from "../../src/APL/redis"; 5 | 6 | // These tests require a running Redis instance 7 | // Run with: INTEGRATION=1 pnpm test 8 | const runIntegrationTests = process.env.INTEGRATION === "1"; 9 | const testFn = runIntegrationTests ? describe : describe.skip; 10 | 11 | testFn("Redis APL Integration", () => { 12 | const client = createClient({ 13 | url: process.env.REDIS_URL || "redis://localhost:6379", 14 | }); 15 | 16 | let apl: RedisAPL; 17 | 18 | beforeAll(async () => { 19 | await client.connect(); 20 | apl = new RedisAPL({ client }); 21 | // Clear any existing test data 22 | const allKeys = await client.hGetAll("saleor_app_auth"); 23 | for (const key of Object.keys(allKeys)) { 24 | await client.hDel("saleor_app_auth", key); 25 | } 26 | }); 27 | 28 | afterAll(async () => { 29 | await client.quit(); 30 | }); 31 | 32 | it("should successfully connect to Redis", async () => { 33 | const result = await client.ping(); 34 | expect(result).toBe("PONG"); 35 | }); 36 | 37 | it("should store and retrieve auth data", async () => { 38 | const testAuthData = { 39 | token: "test-token", 40 | saleorApiUrl: "https://test-store.saleor.cloud/graphql/", 41 | appId: "test-app-id", 42 | }; 43 | 44 | await apl.set(testAuthData); 45 | const retrieved = await apl.get(testAuthData.saleorApiUrl); 46 | 47 | expect(retrieved).toEqual(testAuthData); 48 | }); 49 | 50 | it("should delete auth data", async () => { 51 | const testAuthData = { 52 | token: "test-token-2", 53 | saleorApiUrl: "https://test-store-2.saleor.cloud/graphql/", 54 | appId: "test-app-id-2", 55 | }; 56 | 57 | await apl.set(testAuthData); 58 | await apl.delete(testAuthData.saleorApiUrl); 59 | 60 | const retrieved = await apl.get(testAuthData.saleorApiUrl); 61 | expect(retrieved).toBeUndefined(); 62 | }); 63 | 64 | it("should list all stored auth data", async () => { 65 | // Clear any existing data first 66 | const existingData = await apl.getAll(); 67 | for (const data of existingData) { 68 | await apl.delete(data.saleorApiUrl); 69 | } 70 | 71 | const testData1 = { 72 | token: "test-token-1", 73 | saleorApiUrl: "https://test1.saleor.cloud/graphql/", 74 | appId: "test-app-id-1", 75 | }; 76 | 77 | const testData2 = { 78 | token: "test-token-2", 79 | saleorApiUrl: "https://test2.saleor.cloud/graphql/", 80 | appId: "test-app-id-2", 81 | }; 82 | 83 | await apl.set(testData1); 84 | await apl.set(testData2); 85 | 86 | const allData = await apl.getAll(); 87 | 88 | expect(allData).toHaveLength(2); 89 | expect(allData).toEqual(expect.arrayContaining([testData1, testData2])); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2021", 5 | "lib": [ 6 | "dom", 7 | "ES2021" 8 | ], 9 | "jsx": "react", 10 | "useDefineForClassFields": false, 11 | "module": "commonjs", 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { 5 | types: "src/types.ts", 6 | headers: "src/headers.ts", 7 | "util/index": "src/util/public/index.ts", 8 | "util/browser": "src/util/public/browser/index.ts", 9 | "saleor-app": "src/saleor-app.ts", 10 | "auth/index": "src/auth/index.ts", 11 | /** 12 | * APLs 13 | */ 14 | "APL/index": "src/APL/index.ts", 15 | "APL/redis/index": "src/APL/redis/index.ts", 16 | "APL/upstash/index": "src/APL/upstash/index.ts", 17 | "APL/vercel-kv/index": "src/APL/vercel-kv/index.ts", 18 | "APL/env/index": "src/APL/env/index.ts", 19 | "APL/file/index": "src/APL/file/index.ts", 20 | "APL/saleor-cloud/index": "src/APL/saleor-cloud/index.ts", 21 | 22 | "app-bridge/index": "src/app-bridge/index.ts", 23 | "app-bridge/next/index": "src/app-bridge/next/index.ts", 24 | "settings-manager/index": "src/settings-manager/index.ts", 25 | "handlers/shared/index": "src/handlers/shared/index.ts", 26 | 27 | // Mapped exports 28 | "handlers/next/index": "src/handlers/platforms/next/index.ts", 29 | "handlers/fetch-api/index": "src/handlers/platforms/fetch-api/index.ts", 30 | "handlers/aws-lambda/index": "src/handlers/platforms/aws-lambda/index.ts", 31 | "handlers/next-app-router/index": "src/handlers/platforms/next-app-router/index.ts", 32 | }, 33 | dts: true, 34 | clean: true, 35 | format: ["esm", "cjs"], 36 | splitting: true, 37 | external: ["**/*.md"], 38 | }); 39 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import tsconfigPaths from "vite-tsconfig-paths" 3 | import { defineConfig } from "vitest/config"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | test: { 9 | setupFiles: ["./src/setup-tests.ts"], 10 | environment: "jsdom", 11 | css: false, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------