diff --git a/.changeset/fluffy-cheetahs-sleep.md b/.changeset/fluffy-cheetahs-sleep.md new file mode 100644 index 00000000..6b11227c --- /dev/null +++ b/.changeset/fluffy-cheetahs-sleep.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Added `handlers/fetch-api` which adds support for frameworks that use [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) diff --git a/package.json b/package.json index 74e9fdad..2afa2030 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@types/react-dom": "^18.0.5", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.36.1", - "@typescript-eslint/parser": "^5.36.1", + "@typescript-eslint/parser": "^7.1.1", "@vercel/kv": "1.0.0", "@vitejs/plugin-react": "^3.0.1", "@vitest/coverage-c8": "^0.27.2", @@ -72,7 +72,7 @@ "react-dom": "18.2.0", "tsm": "^2.2.2", "tsup": "^6.2.3", - "typescript": "4.9.5", + "typescript": "5.4.2", "vi-fetch": "^0.8.0", "vite": "^4.0.4", "vitest": "^0.28.1" @@ -108,6 +108,11 @@ "import": "./settings-manager/index.mjs", "require": "./settings-manager/index.js" }, + "./fetch-middleware": { + "types": "./fetch-middleware/index.d.ts", + "import": "./fetch-middleware/index.mjs", + "require": "./fetch-middleware/index.js" + }, "./middleware": { "types": "./middleware/index.d.ts", "import": "./middleware/index.mjs", @@ -133,6 +138,16 @@ "import": "./handlers/next/index.mjs", "require": "./handlers/next/index.js" }, + "./handlers/fetch-api": { + "types": "./handlers/fetch-api/index.d.ts", + "import": "./handlers/fetch-api/index.mjs", + "require": "./handlers/fetch-api/index.js" + }, + "./handlers/shared": { + "types": "./handlers/shared/index.d.ts", + "import": "./handlers/shared/index.mjs", + "require": "./handlers/shared/index.js" + }, "./saleor-app": { "types": "./saleor-app.d.ts", "import": "./saleor-app.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 412d3491..9099d60e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@opentelemetry/api': specifier: ^1.7.0 @@ -50,10 +54,10 @@ devDependencies: version: 8.3.4 '@typescript-eslint/eslint-plugin': specifier: ^5.36.1 - version: 5.36.1(@typescript-eslint/parser@5.36.1)(eslint@8.23.0)(typescript@4.9.5) + version: 5.36.1(@typescript-eslint/parser@7.1.1)(eslint@8.23.0)(typescript@5.4.2) '@typescript-eslint/parser': - specifier: ^5.36.1 - version: 5.36.1(eslint@8.23.0)(typescript@4.9.5) + specifier: ^7.1.1 + version: 7.1.1(eslint@8.23.0)(typescript@5.4.2) '@vercel/kv': specifier: 1.0.0 version: 1.0.0 @@ -74,7 +78,7 @@ devDependencies: version: 19.0.4(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.6.1)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-react@7.31.6)(eslint@8.23.0) eslint-config-airbnb-typescript: specifier: ^17.1.0 - version: 17.1.0(@typescript-eslint/eslint-plugin@5.36.1)(@typescript-eslint/parser@5.36.1)(eslint-plugin-import@2.26.0)(eslint@8.23.0) + version: 17.1.0(@typescript-eslint/eslint-plugin@5.36.1)(@typescript-eslint/parser@7.1.1)(eslint-plugin-import@2.26.0)(eslint@8.23.0) eslint-config-prettier: specifier: ^8.5.0 version: 8.5.0(eslint@8.23.0) @@ -83,7 +87,7 @@ devDependencies: version: 3.5.0(eslint-plugin-import@2.26.0)(eslint@8.23.0) eslint-plugin-import: specifier: ^2.26.0 - version: 2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + version: 2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) eslint-plugin-jsx-a11y: specifier: ^6.6.1 version: 6.6.1(eslint@8.23.0) @@ -128,10 +132,10 @@ devDependencies: version: 2.2.2 tsup: specifier: ^6.2.3 - version: 6.2.3(typescript@4.9.5) + version: 6.2.3(typescript@5.4.2) typescript: - specifier: 4.9.5 - version: 4.9.5 + specifier: 5.4.2 + version: 5.4.2 vi-fetch: specifier: ^0.8.0 version: 0.8.0 @@ -1195,7 +1199,7 @@ packages: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} dev: true - /@typescript-eslint/eslint-plugin@5.36.1(@typescript-eslint/parser@5.36.1)(eslint@8.23.0)(typescript@4.9.5): + /@typescript-eslint/eslint-plugin@5.36.1(@typescript-eslint/parser@7.1.1)(eslint@8.23.0)(typescript@5.4.2): resolution: {integrity: sha512-iC40UK8q1tMepSDwiLbTbMXKDxzNy+4TfPWgIL661Ym0sD42vRcQU93IsZIrmi+x292DBr60UI/gSwfdVYexCA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1206,38 +1210,39 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/parser': 7.1.1(eslint@8.23.0)(typescript@5.4.2) '@typescript-eslint/scope-manager': 5.36.1 - '@typescript-eslint/type-utils': 5.36.1(eslint@8.23.0)(typescript@4.9.5) - '@typescript-eslint/utils': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/type-utils': 5.36.1(eslint@8.23.0)(typescript@5.4.2) + '@typescript-eslint/utils': 5.36.1(eslint@8.23.0)(typescript@5.4.2) debug: 4.3.4 eslint: 8.23.0 functional-red-black-tree: 1.0.1 ignore: 5.2.0 regexpp: 3.2.0 semver: 7.5.4 - tsutils: 3.21.0(typescript@4.9.5) - typescript: 4.9.5 + tsutils: 3.21.0(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5): - resolution: {integrity: sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2): + resolution: {integrity: sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 5.36.1 - '@typescript-eslint/types': 5.36.1 - '@typescript-eslint/typescript-estree': 5.36.1(typescript@4.9.5) + '@typescript-eslint/scope-manager': 7.1.1 + '@typescript-eslint/types': 7.1.1 + '@typescript-eslint/typescript-estree': 7.1.1(typescript@5.4.2) + '@typescript-eslint/visitor-keys': 7.1.1 debug: 4.3.4 eslint: 8.23.0 - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: true @@ -1250,7 +1255,15 @@ packages: '@typescript-eslint/visitor-keys': 5.36.1 dev: true - /@typescript-eslint/type-utils@5.36.1(eslint@8.23.0)(typescript@4.9.5): + /@typescript-eslint/scope-manager@7.1.1: + resolution: {integrity: sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.1.1 + '@typescript-eslint/visitor-keys': 7.1.1 + dev: true + + /@typescript-eslint/type-utils@5.36.1(eslint@8.23.0)(typescript@5.4.2): resolution: {integrity: sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1260,12 +1273,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.36.1(typescript@4.9.5) - '@typescript-eslint/utils': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/typescript-estree': 5.36.1(typescript@5.4.2) + '@typescript-eslint/utils': 5.36.1(eslint@8.23.0)(typescript@5.4.2) debug: 4.3.4 eslint: 8.23.0 - tsutils: 3.21.0(typescript@4.9.5) - typescript: 4.9.5 + tsutils: 3.21.0(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: true @@ -1275,7 +1288,12 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree@5.36.1(typescript@4.9.5): + /@typescript-eslint/types@7.1.1: + resolution: {integrity: sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.36.1(typescript@5.4.2): resolution: {integrity: sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1290,13 +1308,35 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@4.9.5) - typescript: 4.9.5 + tsutils: 3.21.0(typescript@5.4.2) + typescript: 5.4.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/typescript-estree@7.1.1(typescript@5.4.2): + resolution: {integrity: sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 7.1.1 + '@typescript-eslint/visitor-keys': 7.1.1 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.36.1(eslint@8.23.0)(typescript@4.9.5): + /@typescript-eslint/utils@5.36.1(eslint@8.23.0)(typescript@5.4.2): resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1305,7 +1345,7 @@ packages: '@types/json-schema': 7.0.11 '@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/types': 5.36.1 - '@typescript-eslint/typescript-estree': 5.36.1(typescript@4.9.5) + '@typescript-eslint/typescript-estree': 5.36.1(typescript@5.4.2) eslint: 8.23.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0(eslint@8.23.0) @@ -1322,6 +1362,14 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@typescript-eslint/visitor-keys@7.1.1: + resolution: {integrity: sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.1.1 + eslint-visitor-keys: 3.4.3 + dev: true + /@upstash/redis@1.24.3: resolution: {integrity: sha512-gw6d4IA1biB4eye5ESaXc0zOlVQI94aptsBvVcTghYWu1kRmOrJFoMFEDCa8p5uzluyYAOFCuY2GWLR6O4ZoIw==} dependencies: @@ -1637,6 +1685,12 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} @@ -1791,7 +1845,7 @@ packages: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /ci-info@3.9.0: @@ -2724,13 +2778,13 @@ packages: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.23.0 - eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) object.assign: 4.1.4 object.entries: 1.1.5 semver: 6.3.0 dev: true - /eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.36.1)(@typescript-eslint/parser@5.36.1)(eslint-plugin-import@2.26.0)(eslint@8.23.0): + /eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.36.1)(@typescript-eslint/parser@7.1.1)(eslint-plugin-import@2.26.0)(eslint@8.23.0): resolution: {integrity: sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==} peerDependencies: '@typescript-eslint/eslint-plugin': ^5.13.0 || ^6.0.0 @@ -2738,11 +2792,11 @@ packages: eslint: ^7.32.0 || ^8.2.0 eslint-plugin-import: ^2.25.3 dependencies: - '@typescript-eslint/eslint-plugin': 5.36.1(@typescript-eslint/parser@5.36.1)(eslint@8.23.0)(typescript@4.9.5) - '@typescript-eslint/parser': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/eslint-plugin': 5.36.1(@typescript-eslint/parser@7.1.1)(eslint@8.23.0)(typescript@5.4.2) + '@typescript-eslint/parser': 7.1.1(eslint@8.23.0)(typescript@5.4.2) eslint: 8.23.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.26.0)(eslint@8.23.0) - eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) dev: true /eslint-config-airbnb@19.0.4(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.6.1)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-react@7.31.6)(eslint@8.23.0): @@ -2757,7 +2811,7 @@ packages: dependencies: eslint: 8.23.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.26.0)(eslint@8.23.0) - eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) eslint-plugin-jsx-a11y: 6.6.1(eslint@8.23.0) eslint-plugin-react: 7.31.6(eslint@8.23.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.23.0) @@ -2793,7 +2847,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.10.0 eslint: 8.23.0 - eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) get-tsconfig: 4.2.0 globby: 13.1.2 is-core-module: 2.10.0 @@ -2803,7 +2857,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.7.4(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): + /eslint-module-utils@2.7.4(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} engines: {node: '>=4'} peerDependencies: @@ -2824,7 +2878,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/parser': 7.1.1(eslint@8.23.0)(typescript@5.4.2) debug: 3.2.7 eslint: 8.23.0 eslint-import-resolver-node: 0.3.6 @@ -2833,7 +2887,7 @@ packages: - supports-color dev: true - /eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): + /eslint-plugin-import@2.26.0(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==} engines: {node: '>=4'} peerDependencies: @@ -2843,14 +2897,14 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.36.1(eslint@8.23.0)(typescript@4.9.5) + '@typescript-eslint/parser': 7.1.1(eslint@8.23.0)(typescript@5.4.2) array-includes: 3.1.5 array.prototype.flat: 1.3.0 debug: 2.6.9 doctrine: 2.1.0 eslint: 8.23.0 eslint-import-resolver-node: 0.3.6 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.36.1)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-module-utils: 2.7.4(@typescript-eslint/parser@7.1.1)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) has: 1.0.3 is-core-module: 2.10.0 is-glob: 4.0.3 @@ -2962,6 +3016,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + /eslint@8.23.0: resolution: {integrity: sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3224,8 +3283,8 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -4158,6 +4217,13 @@ packages: brace-expansion: 1.1.11 dev: true + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -4909,7 +4975,7 @@ packages: engines: {node: '>=10.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /rollup@3.10.1: @@ -4917,7 +4983,7 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /run-parallel@1.2.0: @@ -4974,6 +5040,14 @@ packages: lru-cache: 6.0.0 dev: true + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true @@ -5441,6 +5515,15 @@ packages: engines: {node: '>=8'} dev: true + /ts-api-utils@1.2.1(typescript@5.4.2): + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.4.2 + dev: true + /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true @@ -5470,7 +5553,7 @@ packages: esbuild: 0.14.54 dev: true - /tsup@6.2.3(typescript@4.9.5): + /tsup@6.2.3(typescript@5.4.2): resolution: {integrity: sha512-J5Pu2Dx0E1wlpIEsVFv9ryzP1pZ1OYsJ2cBHZ7GrKteytNdzaSz5hmLX7/nAxtypq+jVkVvA79d7S83ETgHQ5w==} engines: {node: '>=14'} hasBin: true @@ -5500,20 +5583,20 @@ packages: source-map: 0.8.0-beta.0 sucrase: 3.25.0 tree-kill: 1.2.2 - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color - ts-node dev: true - /tsutils@3.21.0(typescript@4.9.5): + /tsutils@3.21.0(typescript@5.4.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 4.9.5 + typescript: 5.4.2 dev: true /tty-table@4.1.6: @@ -5582,9 +5665,9 @@ packages: mime-types: 2.1.35 dev: true - /typescript@4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} + /typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + engines: {node: '>=14.17'} hasBin: true dev: true @@ -5753,7 +5836,7 @@ packages: resolve: 1.22.1 rollup: 3.10.1 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /vitest@0.27.2(jsdom@20.0.3): @@ -6114,7 +6197,3 @@ packages: dev: false publishDirectory: dist - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/src/APL/vercel-kv/vercel-kv-apl.ts b/src/APL/vercel-kv/vercel-kv-apl.ts index cd1ac0fb..d5e93056 100644 --- a/src/APL/vercel-kv/vercel-kv-apl.ts +++ b/src/APL/vercel-kv/vercel-kv-apl.ts @@ -55,7 +55,7 @@ export class VercelKvApl implements APL { span .setStatus({ - code: 200, + code: SpanStatusCode.OK, message: "Received response from VercelKV", }) .end(); @@ -101,7 +101,7 @@ export class VercelKvApl implements APL { span .setStatus({ - code: 200, + code: SpanStatusCode.OK, message: "Successfully written auth data to VercelKV", }) .end(); @@ -140,7 +140,7 @@ export class VercelKvApl implements APL { span .setStatus({ - code: 200, + code: SpanStatusCode.OK, message: "Successfully deleted auth data to VercelKV", }) .end(); diff --git a/src/fetch-middleware/index.ts b/src/fetch-middleware/index.ts new file mode 100644 index 00000000..25af3044 --- /dev/null +++ b/src/fetch-middleware/index.ts @@ -0,0 +1,6 @@ +export * from "./to-request-handler"; +export * from "./with-auth-token-required"; +export * from "./with-method"; +export * from "./with-registered-saleor-domain-header"; +export * from "./with-saleor-app"; +export * from "./with-saleor-domain-present"; diff --git a/src/fetch-middleware/middleware-debug.ts b/src/fetch-middleware/middleware-debug.ts new file mode 100644 index 00000000..8ae78f70 --- /dev/null +++ b/src/fetch-middleware/middleware-debug.ts @@ -0,0 +1,4 @@ +import { createDebug } from "../debug"; + +export const createFetchMiddlewareDebug = (middleware: string) => + createDebug(`FetchMiddleware:${middleware}`); diff --git a/src/fetch-middleware/to-request-handler.ts b/src/fetch-middleware/to-request-handler.ts new file mode 100644 index 00000000..0ca5f375 --- /dev/null +++ b/src/fetch-middleware/to-request-handler.ts @@ -0,0 +1,20 @@ +import { FetchHandler, FetchPipeline, ReveredFetchPipeline } from "./types"; + +const isPipeline = (maybePipeline: unknown): maybePipeline is FetchPipeline => + Array.isArray(maybePipeline); + +const compose = + (...functions: T[]) => + (args: any) => + functions.reduce((arg, fn) => fn(arg), args); + +const preparePipeline = (pipeline: FetchPipeline): FetchHandler => { + const [action, ...middleware] = pipeline.reverse() as ReveredFetchPipeline; + return compose(...middleware)(action); +}; + +export const toRequestHandler = (flow: FetchHandler | FetchPipeline): FetchHandler => { + const handler = isPipeline(flow) ? preparePipeline(flow) : flow; + + return async (request: Request) => handler(request); +}; diff --git a/src/fetch-middleware/types.ts b/src/fetch-middleware/types.ts new file mode 100644 index 00000000..0cdca82d --- /dev/null +++ b/src/fetch-middleware/types.ts @@ -0,0 +1,5 @@ +export type SaleorRequest = Request & { context?: Record }; +export type FetchHandler = (req: SaleorRequest) => Response | Promise; +export type FetchMiddleware = (handler: FetchHandler) => FetchHandler; +export type FetchPipeline = [...FetchMiddleware[], FetchHandler]; +export type ReveredFetchPipeline = [FetchHandler, ...FetchMiddleware[]]; diff --git a/src/fetch-middleware/with-auth-token-required.ts b/src/fetch-middleware/with-auth-token-required.ts new file mode 100644 index 00000000..29327b48 --- /dev/null +++ b/src/fetch-middleware/with-auth-token-required.ts @@ -0,0 +1,32 @@ +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; + +const debug = createFetchMiddlewareDebug("withAuthTokenRequired"); + +export const withAuthTokenRequired: FetchMiddleware = (handler) => async (request) => { + debug("Middleware called"); + + try { + // If we read `request.json()` without cloning it will throw an error + // next time we run request.json() + const clone = request.clone(); + const json = await clone.json(); + const authToken = json.auth_token; + + if (!authToken) { + debug("Found missing authToken param"); + + return Response.json( + { + success: false, + message: "Missing auth token.", + }, + { status: 400 } + ); + } + } catch { + return Response.json({ success: false, message: "Invalid request body" }, { status: 400 }); + } + + return handler(request); +}; diff --git a/src/fetch-middleware/with-method.ts b/src/fetch-middleware/with-method.ts new file mode 100644 index 00000000..0f9effaa --- /dev/null +++ b/src/fetch-middleware/with-method.ts @@ -0,0 +1,28 @@ +import { FetchMiddleware } from "./types"; + +export const HTTPMethod = { + GET: "GET", + POST: "POST", + PUT: "PUT", + PATH: "PATCH", + HEAD: "HEAD", + OPTIONS: "OPTIONS", + DELETE: "DELETE", +} as const; +export type HTTPMethod = typeof HTTPMethod[keyof typeof HTTPMethod]; + +export const withMethod = + (...methods: HTTPMethod[]): FetchMiddleware => + (handler) => + async (request) => { + if (!methods.includes(request.method as HTTPMethod)) { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: methods.join(", ") }, + }); + } + + const response = await handler(request); + + return response; + }; diff --git a/src/fetch-middleware/with-registered-saleor-domain-header.ts b/src/fetch-middleware/with-registered-saleor-domain-header.ts new file mode 100644 index 00000000..6e3edb12 --- /dev/null +++ b/src/fetch-middleware/with-registered-saleor-domain-header.ts @@ -0,0 +1,51 @@ +import { getSaleorHeadersFetchAPI } from "../headers"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; +import { getSaleorAppFromRequest } from "./with-saleor-app"; + +const debug = createFetchMiddlewareDebug("withRegisteredSaleorDomainHeader"); + +export const withRegisteredSaleorDomainHeader: FetchMiddleware = (handler) => async (request) => { + const { saleorApiUrl } = getSaleorHeadersFetchAPI(request.headers); + + if (!saleorApiUrl) { + return Response.json( + { success: false, message: "saleorApiUrl header missing" }, + { status: 400 } + ); + } + + debug("Middleware called with saleorApiUrl: \"%s\"", saleorApiUrl); + + const saleorApp = getSaleorAppFromRequest(request); + + if (!saleorApp) { + console.error( + "SaleorApp not found in request context. Ensure your API handler is wrapped with withSaleorApp middleware" + ); + + return Response.json( + { + success: false, + message: "SaleorApp is misconfigured", + }, + { status: 500 } + ); + } + + const authData = await saleorApp?.apl.get(saleorApiUrl); + + if (!authData) { + debug("Auth was not found in APL, will respond with Forbidden status"); + + return Response.json( + { + success: false, + message: `Saleor: ${saleorApiUrl} not registered.`, + }, + { status: 403 } + ); + } + + return handler(request); +}; diff --git a/src/fetch-middleware/with-saleor-app.ts b/src/fetch-middleware/with-saleor-app.ts new file mode 100644 index 00000000..b95a754f --- /dev/null +++ b/src/fetch-middleware/with-saleor-app.ts @@ -0,0 +1,20 @@ +import { SaleorApp } from "../saleor-app"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware, SaleorRequest } from "./types"; + +const debug = createFetchMiddlewareDebug("withSaleorApp"); + +export const withSaleorApp = + (saleorApp: SaleorApp): FetchMiddleware => + (handler) => + async (request: SaleorRequest) => { + debug("Middleware called"); + + request.context ??= {}; + request.context.saleorApp = saleorApp; + + return handler(request); + }; + +export const getSaleorAppFromRequest = (request: SaleorRequest): SaleorApp | undefined => + request.context?.saleorApp; diff --git a/src/fetch-middleware/with-saleor-domain-present.ts b/src/fetch-middleware/with-saleor-domain-present.ts new file mode 100644 index 00000000..9955aa38 --- /dev/null +++ b/src/fetch-middleware/with-saleor-domain-present.ts @@ -0,0 +1,25 @@ +import { getSaleorHeadersFetchAPI } from "../headers"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; + +const debug = createFetchMiddlewareDebug("withSaleorDomainPresent"); + +export const withSaleorDomainPresent: FetchMiddleware = (handler) => async (request) => { + const { domain } = getSaleorHeadersFetchAPI(request.headers); + + debug("Middleware called with domain in header: %s", domain); + + if (!domain) { + debug("Domain not found in header, will respond with Bad Request"); + + return Response.json( + { + success: false, + message: "Missing Saleor domain header.", + }, + { status: 400 } + ); + } + + return handler(request); +}; diff --git a/src/handlers/fetch-api/create-app-register-handler.ts b/src/handlers/fetch-api/create-app-register-handler.ts new file mode 100644 index 00000000..2d96c062 --- /dev/null +++ b/src/handlers/fetch-api/create-app-register-handler.ts @@ -0,0 +1,248 @@ +import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../../const"; +import { createDebug } from "../../debug"; +import { toRequestHandler } from "../../fetch-middleware/to-request-handler"; +import { FetchHandler } from "../../fetch-middleware/types"; +import { withAuthTokenRequired } from "../../fetch-middleware/with-auth-token-required"; +import { withMethod } from "../../fetch-middleware/with-method"; +import { withSaleorDomainPresent } from "../../fetch-middleware/with-saleor-domain-present"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; +import { getAppId } from "../../get-app-id"; +import { + CallbackErrorHandler, + GenericCreateAppRegisterHandlerOptions, + HookCallbackErrorParams, +} from "../shared/create-app-register-handler-types"; +import { validateAllowSaleorUrls } from "../shared/validate-allow-saleor-urls"; + +const debug = createDebug("WebApi:createAppRegisterHandler"); + +class RegisterCallbackError extends Error { + public status = 500; + + constructor(errorParams: HookCallbackErrorParams) { + super(errorParams.message); + + if (errorParams.status) { + this.status = errorParams.status; + } + } +} + +const createCallbackError: CallbackErrorHandler = (params: HookCallbackErrorParams) => { + throw new RegisterCallbackError(params); +}; + +export type RegisterHandlerResponseBody = { + success: boolean; + error?: { + code?: string; + message?: string; + }; +}; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"] +): RegisterHandlerResponseBody => ({ + success, + error, +}); + +const handleHookError = (e: RegisterCallbackError | unknown) => { + if (e instanceof RegisterCallbackError) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: e.message, + }), + { status: e.status } + ); + } + return new Response("Error during app installation", { status: 500 }); +}; + +// Request type is from Web API +export type CreateAppRegisterHandlerOptions = GenericCreateAppRegisterHandlerOptions; + +/** + * Creates API handler for Next.js. Creates handler called by Saleor that registers app. + * Hides implementation details if possible + * In the future this will be extracted to separate sdk/next package + */ +export const createAppRegisterHandler = ({ + apl, + allowedSaleorUrls, + onAplSetFailed, + onAuthAplSaved, + onRequestVerified, + onRequestStart, +}: CreateAppRegisterHandlerOptions) => { + const baseHandler: FetchHandler = async (inputRequest) => { + debug("Request received"); + + const request = inputRequest.clone(); + const json = await request.json(); + const authToken = json.auth_token; + const saleorDomain = request.headers.get(SALEOR_DOMAIN_HEADER) as string; + const saleorApiUrl = request.headers.get(SALEOR_API_URL_HEADER) as string; + + if (onRequestStart) { + debug("Calling \"onRequestStart\" hook"); + + try { + await onRequestStart(request, { + authToken, + saleorApiUrl, + saleorDomain, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestStart\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + + if (!saleorApiUrl) { + debug("saleorApiUrl doesn't exist in headers"); + } + + if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { + debug( + "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", + saleorApiUrl + ); + + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "SALEOR_URL_PROHIBITED", + message: "This app expects to be installed only in allowed Saleor instances", + }), + { status: 403 } + ); + } + + const { configured: aplConfigured } = await apl.isConfigured(); + + if (!aplConfigured) { + debug("The APL has not been configured"); + + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "APL_NOT_CONFIGURED", + message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", + }), + { + status: 503, + } + ); + } + + // Try to get App ID from the API, to confirm that communication can be established + const appId = await getAppId({ saleorApiUrl, token: authToken }); + if (!appId) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "UNKNOWN_APP_ID", + message: `The auth data given during registration request could not be used to fetch app ID. + This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${saleorApiUrl}`, + }), + { + status: 401, + } + ); + } + + // Fetch the JWKS which will be used during webhook validation + const jwks = await fetchRemoteJwks(saleorApiUrl); + if (!jwks) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }), + { + status: 401, + } + ); + } + + const authData = { + domain: saleorDomain, + token: authToken, + saleorApiUrl, + appId, + jwks, + }; + + if (onRequestVerified) { + debug("Calling \"onRequestVerified\" hook"); + + try { + await onRequestVerified(request, { + authData, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestVerified\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + + try { + await apl.set(authData); + + if (onAuthAplSaved) { + debug("Calling \"onAuthAplSaved\" hook"); + + try { + await onAuthAplSaved(request, { + authData, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onAuthAplSaved\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + } catch (aplError: unknown) { + debug("There was an error during saving the auth data"); + + if (onAplSetFailed) { + debug("Calling \"onAuthAplFailed\" hook"); + + try { + await onAplSetFailed(request, { + authData, + error: aplError, + respondWithError: createCallbackError, + }); + } catch (hookError: RegisterCallbackError | unknown) { + debug("\"onAuthAplFailed\" hook thrown error: %o", hookError); + + return handleHookError(hookError); + } + } + + return Response.json( + createRegisterHandlerResponseBody(false, { + message: "Registration failed: could not save the auth data.", + }), + { status: 500 } + ); + } + + debug("Register complete"); + + return Response.json(createRegisterHandlerResponseBody(true)); + }; + + return toRequestHandler([ + withMethod("POST"), + withSaleorDomainPresent, + withAuthTokenRequired, + baseHandler, + ]); +}; diff --git a/src/handlers/fetch-api/create-manifest-handler.ts b/src/handlers/fetch-api/create-manifest-handler.ts new file mode 100644 index 00000000..fe554a76 --- /dev/null +++ b/src/handlers/fetch-api/create-manifest-handler.ts @@ -0,0 +1,25 @@ +import { getBaseUrlFetchAPI, getSaleorHeadersFetchAPI } from "../../headers"; +import { AppManifest } from "../../types"; + +export type CreateManifestHandlerOptions = { + manifestFactory(context: { + appBaseUrl: string; + request: Request; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; + }): AppManifest | Promise; +}; + +export const createManifestHandler = + (options: CreateManifestHandlerOptions) => async (request: Request) => { + const { schemaVersion } = getSaleorHeadersFetchAPI(request.headers); + const baseURL = getBaseUrlFetchAPI(request); + + const manifest = await options.manifestFactory({ + appBaseUrl: baseURL, + request, + schemaVersion, + }); + + return Response.json(manifest); + }; diff --git a/src/handlers/fetch-api/create-protected-handler.ts b/src/handlers/fetch-api/create-protected-handler.ts new file mode 100644 index 00000000..e035cd0a --- /dev/null +++ b/src/handlers/fetch-api/create-protected-handler.ts @@ -0,0 +1,40 @@ +import { APL } from "../../APL"; +import { createDebug } from "../../debug"; +import { Permission } from "../../types"; +import { ProtectedHandlerErrorCodeMap } from "../shared/protected-handler"; +import { ProtectedHandlerContext } from "../shared/protected-handler-context"; +import { processSaleorProtectedHandler, ProtectedHandlerError } from "./process-protected-handler"; + +const debug = createDebug("WebAPI:ProtectedHandler"); + +export type WebApiProtectedHandler = ( + request: Request, + ctx: ProtectedHandlerContext +) => Response | Promise; + +export const createProtectedHandler = + ( + handlerFn: WebApiProtectedHandler, + apl: APL, + requiredPermissions?: Permission[] + ): WebApiProtectedHandler => + (request) => { + debug("Protected handler called"); + return processSaleorProtectedHandler({ request, apl, requiredPermissions }) + .then(async (ctx) => { + debug("Incoming request validated. Call handlerFn"); + return handlerFn(request, ctx); + }) + .catch((e) => { + debug("Unexpected error during processing the request"); + + if (e instanceof ProtectedHandlerError) { + debug(`Validation error: ${e.message}`); + return new Response("Invalid request", { + status: ProtectedHandlerErrorCodeMap[e.errorType] || 400, + }); + } + debug("Unexpected error: %O", e); + return new Response("Unexpected error while handling request", { status: 500 }); + }); + }; diff --git a/src/handlers/fetch-api/index.ts b/src/handlers/fetch-api/index.ts new file mode 100644 index 00000000..3bc2ff5a --- /dev/null +++ b/src/handlers/fetch-api/index.ts @@ -0,0 +1,6 @@ +export * from "./create-app-register-handler"; +export * from "./create-manifest-handler"; +export * from "./create-protected-handler"; +export * from "./process-protected-handler"; +export * from "./saleor-webhooks/saleor-async-webhook"; +export * from "./saleor-webhooks/saleor-sync-webhook"; diff --git a/src/handlers/fetch-api/process-protected-handler.ts b/src/handlers/fetch-api/process-protected-handler.ts new file mode 100644 index 00000000..8edb38e5 --- /dev/null +++ b/src/handlers/fetch-api/process-protected-handler.ts @@ -0,0 +1,163 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { APL } from "../../APL"; +import { createDebug } from "../../debug"; +import { getBaseUrlFetchAPI, getSaleorHeadersFetchAPI } from "../../headers"; +import { getOtelTracer } from "../../open-telemetry"; +import { Permission } from "../../types"; +import { extractUserFromJwt } from "../../util/extract-user-from-jwt"; +import { verifyJWT } from "../../verify-jwt"; +import { ProtectedHandlerContext } from "../shared/protected-handler-context"; + +const debug = createDebug("WebAPI:processProtectedHandler"); + +export type SaleorProtectedHandlerError = + | "OTHER" + | "MISSING_HOST_HEADER" + | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" + | "MISSING_AUTHORIZATION_BEARER_HEADER" + | "NOT_REGISTERED" + | "JWT_VERIFICATION_FAILED" + | "NO_APP_ID"; + +export class ProtectedHandlerError extends Error { + errorType: SaleorProtectedHandlerError = "OTHER"; + + constructor(message: string, errorType: SaleorProtectedHandlerError) { + super(message); + if (errorType) { + this.errorType = errorType; + } + Object.setPrototypeOf(this, ProtectedHandlerError.prototype); + } +} + +interface ProcessSaleorProtectedHandlerArgs { + request: Request; + apl: APL; + requiredPermissions?: Permission[]; +} + +type ProcessAsyncSaleorProtectedHandler = ( + props: ProcessSaleorProtectedHandlerArgs +) => Promise; + +/** + * Perform security checks on given request and return ProtectedHandlerContext object. + * In case of validation issues, instance of the ProtectedHandlerError will be thrown. + * + * Can pass entire next request or Headers with saleorApiUrl and token + */ +export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ + request, + apl, + requiredPermissions, +}: ProcessSaleorProtectedHandlerArgs): Promise => { + const tracer = getOtelTracer(); + + return tracer.startActiveSpan( + "processSaleorProtectedHandler", + { + kind: SpanKind.INTERNAL, + attributes: { + requiredPermissions, + }, + }, + async (span) => { + debug("Request processing started"); + + const { saleorApiUrl, authorizationBearer: token } = getSaleorHeadersFetchAPI( + request.headers + ); + + const baseUrl = getBaseUrlFetchAPI(request); + + span.setAttribute("saleorApiUrl", saleorApiUrl ?? ""); + + if (!baseUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing host header", + }) + .end(); + + debug("Missing host header"); + + throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing saleor-api-url header", + }) + .end(); + + debug("Missing saleor-api-url header"); + + throw new ProtectedHandlerError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!token) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing authorization-bearer header", + }) + .end(); + + debug("Missing authorization-bearer header"); + + throw new ProtectedHandlerError( + "Missing authorization-bearer header", + "MISSING_AUTHORIZATION_BEARER_HEADER" + ); + } + + // Check if API URL has been registered in the APL + const authData = await apl.get(saleorApiUrl); + + if (!authData) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "APL didn't found auth data for API URL", + }) + .end(); + + debug("APL didn't found auth data for API URL %s", saleorApiUrl); + + throw new ProtectedHandlerError( + `Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`, + "NOT_REGISTERED" + ); + } + + try { + await verifyJWT({ appId: authData.appId, token, saleorApiUrl, requiredPermissions }); + } catch (e) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "JWT verification failed", + }) + .end(); + + throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); + } + + const userJwtPayload = extractUserFromJwt(token); + + span.end(); + + return { + baseUrl, + authData, + user: userJwtPayload, + }; + } + ); +}; diff --git a/src/handlers/fetch-api/saleor-webhooks/process-saleor-webhook.ts b/src/handlers/fetch-api/saleor-webhooks/process-saleor-webhook.ts new file mode 100644 index 00000000..528642a4 --- /dev/null +++ b/src/handlers/fetch-api/saleor-webhooks/process-saleor-webhook.ts @@ -0,0 +1,162 @@ +import { APL } from "../../../APL"; +import { createDebug } from "../../../debug"; +import { fetchRemoteJwks } from "../../../fetch-remote-jwks"; +import { getBaseUrlFetchAPI, getSaleorHeadersFetchAPI } from "../../../headers"; +import { parseSchemaVersion } from "../../../util"; +import { verifySignatureWithJwks } from "../../../verify-signature"; +import { WebhookContext, WebhookError } from "../../shared/process-saleor-webhook"; + +const debug = createDebug("WebAPI:processSaleorWebhook"); + +interface ProcessSaleorWebhookArgs { + req: Request; + apl: APL; + allowedEvent: string; +} + +export const processSaleorWebhook = async ({ + req, + apl, + allowedEvent, +}: ProcessSaleorWebhookArgs): Promise> => { + // TODO: Add OTEL + + try { + debug("Request processing started"); + + if (req.method !== "POST") { + debug("Wrong HTTP method"); + throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); + } + + const { event, signature, saleorApiUrl } = getSaleorHeadersFetchAPI(req.headers); + const baseUrl = getBaseUrlFetchAPI(req); + + if (!baseUrl) { + debug("Missing host header"); + throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + debug("Missing saleor-api-url header"); + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!event) { + debug("Missing saleor-event header"); + throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); + } + + const expected = allowedEvent.toLowerCase(); + + if (event !== expected) { + debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); + + throw new WebhookError( + `Wrong incoming request event: ${event}. Expected: ${expected}`, + "WRONG_EVENT" + ); + } + + if (!signature) { + debug("No signature"); + + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); + } + + let rawBody: string; + + try { + rawBody = await req.text(); + } catch (err) { + throw new WebhookError("Error reading request body", "CANT_BE_PARSED"); + } + + if (!rawBody) { + debug("Missing request body"); + + throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); + } + + let parsedBody: unknown & { version?: string | null }; + + try { + parsedBody = JSON.parse(rawBody); + } catch (err) { + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); + } + + let parsedSchemaVersion: number | null = null; + + try { + parsedSchemaVersion = parseSchemaVersion(parsedBody.version); + } catch { + debug("Schema version cannot be parsed"); + } + + /** + * Verify if the app is properly installed for given Saleor API URL + */ + const authData = await apl.get(saleorApiUrl); + + if (!authData) { + debug("APL didn't found auth data for %s", saleorApiUrl); + + throw new WebhookError( + `Can't find auth data for ${saleorApiUrl}. Please register the application`, + "NOT_REGISTERED" + ); + } + + /** + * Verify payload signature + * + * TODO: Add test for repeat verification scenario + */ + try { + debug("Will verify signature with JWKS saved in AuthData"); + + if (!authData.jwks) { + throw new Error("JWKS not found in AuthData"); + } + + await verifySignatureWithJwks(authData.jwks, signature, rawBody); + } catch { + debug("Request signature check failed. Refresh the JWKS cache and check again"); + + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + debug(e); + + throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED"); + }); + + debug("Fetched refreshed JWKS"); + + try { + debug("Second attempt to validate the signature JWKS, using fresh tokens from the API"); + + await verifySignatureWithJwks(newJwks, signature, rawBody); + + debug("Verification successful - update JWKS in the AuthData"); + + await apl.set({ ...authData, jwks: newJwks }); + } catch { + debug("Second attempt also ended with validation error. Reject the webhook"); + + throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); + } + } + + return { + baseUrl, + event, + payload: parsedBody as T, + authData, + schemaVersion: parsedSchemaVersion, + }; + } catch (err) { + debug("Unexpected error: %O", err); + + throw err; + } +}; diff --git a/src/handlers/fetch-api/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/fetch-api/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 00000000..71a191cb --- /dev/null +++ b/src/handlers/fetch-api/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,53 @@ +import { ASTNode } from "graphql"; + +import { AsyncWebhookEventType } from "../../../types"; +import { + SaleorWebApiWebhook, + SaleorWebhookHandler, + WebApiRouteHandler, + WebhookConfig, +} from "./saleor-webhook"; + +export class SaleorAsyncWebhook extends SaleorWebApiWebhook { + readonly event: AsyncWebhookEventType; + + protected readonly eventType = "async" as const; + + constructor( + /** + * Omit new required fields and make them optional. Validate in constructor. + * In 0.35.0 remove old fields + */ + configuration: Omit, "event" | "query"> & { + /** + * @deprecated - use `event` instead. Will be removed in 0.35.0 + */ + asyncEvent?: AsyncWebhookEventType; + event?: AsyncWebhookEventType; + query?: string | ASTNode; + } + ) { + if (!configuration.event && !configuration.asyncEvent) { + throw new Error("event or asyncEvent must be provided. asyncEvent is deprecated"); + } + + if (!configuration.query && !configuration.subscriptionQueryAst) { + throw new Error( + "query or subscriptionQueryAst must be provided. subscriptionQueryAst is deprecated" + ); + } + + super({ + ...configuration, + event: configuration.event! ?? configuration.asyncEvent!, + query: configuration.query! ?? configuration.subscriptionQueryAst!, + }); + + this.event = configuration.event! ?? configuration.asyncEvent!; + this.query = configuration.query! ?? configuration.subscriptionQueryAst!; + } + + createHandler(handlerFn: SaleorWebhookHandler): WebApiRouteHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/fetch-api/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/fetch-api/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 00000000..d3cc2e5a --- /dev/null +++ b/src/handlers/fetch-api/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,41 @@ +import { SyncWebhookEventType } from "../../../types"; +import { buildSyncWebhookResponsePayload } from "../../shared/sync-webhook-response-builder"; +import { + SaleorWebApiWebhook, + SaleorWebhookHandler, + WebApiRouteHandler, + WebhookConfig, +} from "./saleor-webhook"; + +type InjectedContext = { + buildResponse: typeof buildSyncWebhookResponsePayload; +}; +export class SaleorSyncWebhook< + TPayload = unknown, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> extends SaleorWebApiWebhook> { + readonly event: TEvent; + + protected readonly eventType = "sync" as const; + + protected extraContext = { + buildResponse: buildSyncWebhookResponsePayload, + }; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler( + handlerFn: SaleorWebhookHandler< + TPayload, + { + buildResponse: typeof buildSyncWebhookResponsePayload; + } + > + ): WebApiRouteHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/fetch-api/saleor-webhooks/saleor-webhook.ts b/src/handlers/fetch-api/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..9d5a1709 --- /dev/null +++ b/src/handlers/fetch-api/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,188 @@ +import { ASTNode } from "graphql"; + +import { APL } from "../../../APL"; +import { createDebug } from "../../../debug"; +import { gqlAstToString } from "../../../gql-ast-to-string"; +import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "../../../types"; +import { WebhookContext, WebhookError } from "../../shared/process-saleor-webhook"; +import { WebhookErrorCodeMap } from "../../shared/saleor-webhook"; +import { processSaleorWebhook } from "./process-saleor-webhook"; + +const debug = createDebug("SaleorWebhook"); + +export interface WebhookConfig { + name?: string; + webhookPath: string; + event: Event; + isActive?: boolean; + apl: APL; + onError?(error: WebhookError | Error, request: Request): void; + formatErrorResponse?( + error: WebhookError | Error, + request: Request + ): Promise<{ + code: number; + body: string; + }>; + query: string | ASTNode; + /** + * @deprecated will be removed in 0.35.0, use query field instead + */ + subscriptionQueryAst?: ASTNode; +} + +/** Generic Web API route handler */ +export type WebApiRouteHandler = (request: Request) => Response | Promise; + +/** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ +export type SaleorWebhookHandler = ( + req: Request, + ctx: WebhookContext & TExtras +) => Response | Promise; + +export abstract class SaleorWebApiWebhook< + TPayload = unknown, + TExtras extends Record = {} +> { + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + + name: string; + + webhookPath: string; + + query: string | ASTNode; + + event: AsyncWebhookEventType | SyncWebhookEventType; + + isActive?: boolean; + + apl: APL; + + onError: WebhookConfig["onError"]; + + formatErrorResponse: WebhookConfig["formatErrorResponse"]; + + protected constructor(configuration: WebhookConfig) { + const { + name, + webhookPath, + event, + query, + apl, + isActive = true, + subscriptionQueryAst, + } = configuration; + + this.name = name || `${event} webhook`; + /** + * Fallback subscriptionQueryAst to avoid breaking changes + * + * TODO Remove in 0.35.0 + */ + this.query = query ?? subscriptionQueryAst; + this.webhookPath = webhookPath; + this.event = event; + this.isActive = isActive; + this.apl = apl; + this.onError = configuration.onError; + this.formatErrorResponse = configuration.formatErrorResponse; + } + + private getTargetUrl(baseUrl: string) { + return new URL(this.webhookPath, baseUrl).href; + } + + /** + * Returns synchronous event manifest for this webhook. + * + * @param baseUrl Base URL used by your application + * @returns WebhookManifest + */ + getWebhookManifest(baseUrl: string): WebhookManifest { + const manifestBase: Omit = { + query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), + name: this.name, + targetUrl: this.getTargetUrl(baseUrl), + isActive: this.isActive, + }; + + switch (this.eventType) { + case "async": + return { + ...manifestBase, + asyncEvents: [this.event as AsyncWebhookEventType], + }; + case "sync": + return { + ...manifestBase, + syncEvents: [this.event as SyncWebhookEventType], + }; + default: { + throw new Error("Class extended incorrectly"); + } + } + } + + /** + * Wraps provided function, to ensure incoming request comes from registered Saleor instance. + * Also provides additional `context` object containing typed payload and request properties. + */ + createHandler(handlerFn: SaleorWebhookHandler): WebApiRouteHandler { + return async (req) => { + debug(`Handler for webhook ${this.name} called`); + + return processSaleorWebhook({ + req, + apl: this.apl, + allowedEvent: this.event, + }) + .then(async (context) => { + debug("Incoming request validated. Call handlerFn"); + + return handlerFn(req, { ...(this.extraContext ?? ({} as TExtras)), ...context }); + }) + .catch(async (e) => { + debug(`Unexpected error during processing the webhook ${this.name}`); + + if (e instanceof WebhookError) { + debug(`Validation error: ${e.message}`); + + if (this.onError) { + this.onError(e, req); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req); + + return new Response(body, { status: code }); + } + + return new Response( + JSON.stringify({ + error: { + type: e.errorType, + message: e.message, + }, + }), + { status: WebhookErrorCodeMap[e.errorType] || 400 } + ); + } + debug("Unexpected error: %O", e); + + if (this.onError) { + this.onError(e, req); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req); + + return new Response(body, { status: code }); + } + + return new Response("Unexpected error while handling request", { status: 500 }); + }); + }; + } +} diff --git a/src/handlers/next/create-app-register-handler.ts b/src/handlers/next/create-app-register-handler.ts index 1e0da6de..edc178ed 100644 --- a/src/handlers/next/create-app-register-handler.ts +++ b/src/handlers/next/create-app-register-handler.ts @@ -3,14 +3,13 @@ import { toNextHandler } from "retes/adapter"; import { withMethod } from "retes/middleware"; import { Response } from "retes/response"; -import { AuthData } from "../../APL"; import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../../const"; import { createDebug } from "../../debug"; import { fetchRemoteJwks } from "../../fetch-remote-jwks"; import { getAppId } from "../../get-app-id"; import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware"; -import { HasAPL } from "../../saleor-app"; -import { validateAllowSaleorUrls } from "./validate-allow-saleor-urls"; +import { GenericCreateAppRegisterHandlerOptions } from "../shared/create-app-register-handler-types"; +import { validateAllowSaleorUrls } from "../shared/validate-allow-saleor-urls"; const debug = createDebug("createAppRegisterHandler"); @@ -63,59 +62,8 @@ const handleHookError = (e: RegisterCallbackError | unknown) => { return Response.InternalServerError("Error during app installation"); }; -export type CreateAppRegisterHandlerOptions = HasAPL & { - /** - * Protect app from being registered in Saleor other than specific. - * By default, allow everything. - * - * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) - * or a function that receives a full Saleor API URL ad returns true/false. - */ - allowedSaleorUrls?: Array boolean)>; - /** - * Run right after Saleor calls this endpoint - */ - onRequestStart?( - request: Request, - context: { - authToken?: string; - saleorDomain?: string; - saleorApiUrl?: string; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after all security checks - */ - onRequestVerified?( - request: Request, - context: { - authData: AuthData; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error - */ - onAuthAplSaved?( - request: Request, - context: { - authData: AuthData; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after APL fails to set AuthData - */ - onAplSetFailed?( - request: Request, - context: { - authData: AuthData; - error: unknown; - respondWithError: typeof createCallbackError; - } - ): Promise; -}; +// Request type is from retest (Next.js interface) +export type CreateAppRegisterHandlerOptions = GenericCreateAppRegisterHandlerOptions; /** * Creates API handler for Next.js. Creates handler called by Saleor that registers app. @@ -155,7 +103,7 @@ export const createAppRegisterHandler = ({ } if (!saleorApiUrl) { - debug("saleorApiUrl doesnt exist in headers"); + debug("saleorApiUrl doesn't exist in headers"); } if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts index 44027670..f8610569 100644 --- a/src/handlers/next/create-protected-handler.ts +++ b/src/handlers/next/create-protected-handler.ts @@ -3,26 +3,12 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { APL } from "../../APL"; import { createDebug } from "../../debug"; import { Permission } from "../../types"; -import { - processSaleorProtectedHandler, - ProtectedHandlerError, - SaleorProtectedHandlerError, -} from "./process-protected-handler"; -import { ProtectedHandlerContext } from "./protected-handler-context"; +import { ProtectedHandlerErrorCodeMap } from "../shared/protected-handler"; +import { ProtectedHandlerContext } from "../shared/protected-handler-context"; +import { processSaleorProtectedHandler, ProtectedHandlerError } from "./process-protected-handler"; const debug = createDebug("ProtectedHandler"); -export const ProtectedHandlerErrorCodeMap: Record = { - OTHER: 500, - MISSING_HOST_HEADER: 400, - MISSING_DOMAIN_HEADER: 400, - MISSING_API_URL_HEADER: 400, - NOT_REGISTERED: 401, - JWT_VERIFICATION_FAILED: 401, - NO_APP_ID: 401, - MISSING_AUTHORIZATION_BEARER_HEADER: 400, -}; - export type NextProtectedApiHandler = ( req: NextApiRequest, res: NextApiResponse, diff --git a/src/handlers/next/index.ts b/src/handlers/next/index.ts index 23ddd049..5480b408 100644 --- a/src/handlers/next/index.ts +++ b/src/handlers/next/index.ts @@ -1,9 +1,10 @@ +// Re-export to avoid breaking changes +export * from "../shared/protected-handler-context"; +export * from "../shared/sync-webhook-response-builder"; export * from "./create-app-register-handler"; export * from "./create-manifest-handler"; export * from "./create-protected-handler"; export * from "./process-protected-handler"; -export * from "./protected-handler-context"; export * from "./saleor-webhooks/saleor-async-webhook"; export * from "./saleor-webhooks/saleor-sync-webhook"; export { NextWebhookApiHandler } from "./saleor-webhooks/saleor-webhook"; -export * from "./saleor-webhooks/sync-webhook-response-builder"; diff --git a/src/handlers/next/process-protected-handler.ts b/src/handlers/next/process-protected-handler.ts index f3f86e3c..020763be 100644 --- a/src/handlers/next/process-protected-handler.ts +++ b/src/handlers/next/process-protected-handler.ts @@ -8,7 +8,7 @@ import { getOtelTracer } from "../../open-telemetry"; import { Permission } from "../../types"; import { extractUserFromJwt } from "../../util/extract-user-from-jwt"; import { verifyJWT } from "../../verify-jwt"; -import { ProtectedHandlerContext } from "./protected-handler-context"; +import { ProtectedHandlerContext } from "../shared/protected-handler-context"; const debug = createDebug("processProtectedHandler"); diff --git a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts index a6451802..237e93df 100644 --- a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts +++ b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts @@ -3,53 +3,16 @@ import { NextApiRequest } from "next"; import getRawBody from "raw-body"; import { APL } from "../../../APL"; -import { AuthData } from "../../../APL/apl"; import { createDebug } from "../../../debug"; import { fetchRemoteJwks } from "../../../fetch-remote-jwks"; import { getBaseUrl, getSaleorHeaders } from "../../../headers"; import { getOtelTracer } from "../../../open-telemetry"; import { parseSchemaVersion } from "../../../util"; import { verifySignatureWithJwks } from "../../../verify-signature"; +import { WebhookContext, WebhookError } from "../../shared/process-saleor-webhook"; const debug = createDebug("processSaleorWebhook"); -export type SaleorWebhookError = - | "OTHER" - | "MISSING_HOST_HEADER" - | "MISSING_DOMAIN_HEADER" - | "MISSING_API_URL_HEADER" - | "MISSING_EVENT_HEADER" - | "MISSING_PAYLOAD_HEADER" - | "MISSING_SIGNATURE_HEADER" - | "MISSING_REQUEST_BODY" - | "WRONG_EVENT" - | "NOT_REGISTERED" - | "SIGNATURE_VERIFICATION_FAILED" - | "WRONG_METHOD" - | "CANT_BE_PARSED" - | "CONFIGURATION_ERROR"; - -export class WebhookError extends Error { - errorType: SaleorWebhookError = "OTHER"; - - constructor(message: string, errorType: SaleorWebhookError) { - super(message); - if (errorType) { - this.errorType = errorType; - } - Object.setPrototypeOf(this, WebhookError.prototype); - } -} - -export type WebhookContext = { - baseUrl: string; - event: string; - payload: T; - authData: AuthData; - /** For Saleor < 3.15 it will be null. */ - schemaVersion: number | null; -}; - interface ProcessSaleorWebhookArgs { req: NextApiRequest; apl: APL; diff --git a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts index 3ab6b63f..79f75220 100644 --- a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts +++ b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts @@ -1,8 +1,8 @@ import { NextApiHandler } from "next"; import { SyncWebhookEventType } from "../../../types"; +import { buildSyncWebhookResponsePayload } from "../../shared/sync-webhook-response-builder"; import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; -import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; type InjectedContext = { buildResponse: typeof buildSyncWebhookResponsePayload; diff --git a/src/handlers/next/saleor-webhooks/saleor-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-webhook.ts index 9a0d7632..3fbf1561 100644 --- a/src/handlers/next/saleor-webhooks/saleor-webhook.ts +++ b/src/handlers/next/saleor-webhooks/saleor-webhook.ts @@ -5,12 +5,9 @@ import { APL } from "../../../APL"; import { createDebug } from "../../../debug"; import { gqlAstToString } from "../../../gql-ast-to-string"; import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "../../../types"; -import { - processSaleorWebhook, - SaleorWebhookError, - WebhookContext, - WebhookError, -} from "./process-saleor-webhook"; +import { WebhookContext, WebhookError } from "../../shared/process-saleor-webhook"; +import { WebhookErrorCodeMap } from "../../shared/saleor-webhook"; +import { processSaleorWebhook } from "./process-saleor-webhook"; const debug = createDebug("SaleorWebhook"); @@ -36,23 +33,6 @@ export interface WebhookConfig = { - OTHER: 500, - MISSING_HOST_HEADER: 400, - MISSING_DOMAIN_HEADER: 400, - MISSING_API_URL_HEADER: 400, - MISSING_EVENT_HEADER: 400, - MISSING_PAYLOAD_HEADER: 400, - MISSING_SIGNATURE_HEADER: 400, - MISSING_REQUEST_BODY: 400, - WRONG_EVENT: 400, - NOT_REGISTERED: 401, - SIGNATURE_VERIFICATION_FAILED: 401, - WRONG_METHOD: 405, - CANT_BE_PARSED: 400, - CONFIGURATION_ERROR: 500, -}; - export type NextWebhookApiHandler = ( req: NextApiRequest, res: NextApiResponse, diff --git a/src/handlers/shared/create-app-register-handler-types.ts b/src/handlers/shared/create-app-register-handler-types.ts new file mode 100644 index 00000000..a35d369d --- /dev/null +++ b/src/handlers/shared/create-app-register-handler-types.ts @@ -0,0 +1,63 @@ +import { AuthData } from "../../APL"; +import { HasAPL } from "../../saleor-app"; + +export type HookCallbackErrorParams = { + status?: number; + message?: string; +}; + +export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never; + +export type GenericCreateAppRegisterHandlerOptions = HasAPL & { + /** + * Protect app from being registered in Saleor other than specific. + * By default, allow everything. + * + * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) + * or a function that receives a full Saleor API URL ad returns true/false. + */ + allowedSaleorUrls?: Array boolean)>; + /** + * Run right after Saleor calls this endpoint + */ + onRequestStart?( + request: RequestType, + context: { + authToken?: string; + saleorDomain?: string; + saleorApiUrl?: string; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after all security checks + */ + onRequestVerified?( + request: RequestType, + context: { + authData: AuthData; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error + */ + onAuthAplSaved?( + request: RequestType, + context: { + authData: AuthData; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after APL fails to set AuthData + */ + onAplSetFailed?( + request: RequestType, + context: { + authData: AuthData; + error: unknown; + respondWithError: CallbackErrorHandler; + } + ): Promise; +}; diff --git a/src/handlers/shared/index.ts b/src/handlers/shared/index.ts new file mode 100644 index 00000000..abb51a05 --- /dev/null +++ b/src/handlers/shared/index.ts @@ -0,0 +1 @@ +export * from "./protected-handler-context"; diff --git a/src/handlers/shared/process-saleor-webhook.ts b/src/handlers/shared/process-saleor-webhook.ts new file mode 100644 index 00000000..ba36e913 --- /dev/null +++ b/src/handlers/shared/process-saleor-webhook.ts @@ -0,0 +1,38 @@ +import { AuthData } from "../../APL"; + +export type SaleorWebhookError = + | "OTHER" + | "MISSING_HOST_HEADER" + | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" + | "MISSING_EVENT_HEADER" + | "MISSING_PAYLOAD_HEADER" + | "MISSING_SIGNATURE_HEADER" + | "MISSING_REQUEST_BODY" + | "WRONG_EVENT" + | "NOT_REGISTERED" + | "SIGNATURE_VERIFICATION_FAILED" + | "WRONG_METHOD" + | "CANT_BE_PARSED" + | "CONFIGURATION_ERROR"; + +export class WebhookError extends Error { + errorType: SaleorWebhookError = "OTHER"; + + constructor(message: string, errorType: SaleorWebhookError) { + super(message); + if (errorType) { + this.errorType = errorType; + } + Object.setPrototypeOf(this, WebhookError.prototype); + } +} + +export type WebhookContext = { + baseUrl: string; + event: string; + payload: T; + authData: AuthData; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; +}; diff --git a/src/handlers/next/protected-handler-context.ts b/src/handlers/shared/protected-handler-context.ts similarity index 100% rename from src/handlers/next/protected-handler-context.ts rename to src/handlers/shared/protected-handler-context.ts diff --git a/src/handlers/shared/protected-handler.ts b/src/handlers/shared/protected-handler.ts new file mode 100644 index 00000000..bcf64907 --- /dev/null +++ b/src/handlers/shared/protected-handler.ts @@ -0,0 +1,12 @@ +import { SaleorProtectedHandlerError } from "../next"; + +export const ProtectedHandlerErrorCodeMap: Record = { + OTHER: 500, + MISSING_HOST_HEADER: 400, + MISSING_DOMAIN_HEADER: 400, + MISSING_API_URL_HEADER: 400, + NOT_REGISTERED: 401, + JWT_VERIFICATION_FAILED: 401, + NO_APP_ID: 401, + MISSING_AUTHORIZATION_BEARER_HEADER: 400, +}; diff --git a/src/handlers/shared/saleor-webhook.ts b/src/handlers/shared/saleor-webhook.ts new file mode 100644 index 00000000..935da295 --- /dev/null +++ b/src/handlers/shared/saleor-webhook.ts @@ -0,0 +1,18 @@ +import { SaleorWebhookError } from "./process-saleor-webhook"; + +export const WebhookErrorCodeMap: Record = { + OTHER: 500, + MISSING_HOST_HEADER: 400, + MISSING_DOMAIN_HEADER: 400, + MISSING_API_URL_HEADER: 400, + MISSING_EVENT_HEADER: 400, + MISSING_PAYLOAD_HEADER: 400, + MISSING_SIGNATURE_HEADER: 400, + MISSING_REQUEST_BODY: 400, + WRONG_EVENT: 400, + NOT_REGISTERED: 401, + SIGNATURE_VERIFICATION_FAILED: 401, + WRONG_METHOD: 405, + CANT_BE_PARSED: 400, + CONFIGURATION_ERROR: 500, +}; diff --git a/src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts b/src/handlers/shared/sync-webhook-response-builder.ts similarity index 98% rename from src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts rename to src/handlers/shared/sync-webhook-response-builder.ts index 952e0cb7..9b690661 100644 --- a/src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts +++ b/src/handlers/shared/sync-webhook-response-builder.ts @@ -1,4 +1,4 @@ -import { SyncWebhookEventType } from "../../../types"; +import { SyncWebhookEventType } from "../../types"; export type SyncWebhookResponsesMap = { CHECKOUT_CALCULATE_TAXES: { diff --git a/src/handlers/next/validate-allow-saleor-urls.test.ts b/src/handlers/shared/validate-allow-saleor-urls.test.ts similarity index 100% rename from src/handlers/next/validate-allow-saleor-urls.test.ts rename to src/handlers/shared/validate-allow-saleor-urls.test.ts diff --git a/src/handlers/next/validate-allow-saleor-urls.ts b/src/handlers/shared/validate-allow-saleor-urls.ts similarity index 84% rename from src/handlers/next/validate-allow-saleor-urls.ts rename to src/handlers/shared/validate-allow-saleor-urls.ts index 9b7c39c5..03875027 100644 --- a/src/handlers/next/validate-allow-saleor-urls.ts +++ b/src/handlers/shared/validate-allow-saleor-urls.ts @@ -1,4 +1,4 @@ -import { CreateAppRegisterHandlerOptions } from "./create-app-register-handler"; +import { CreateAppRegisterHandlerOptions } from "../next/create-app-register-handler"; export const validateAllowSaleorUrls = ( saleorApiUrl: string, diff --git a/src/headers.ts b/src/headers.ts index e5fc701b..b6d91c6a 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -7,10 +7,10 @@ import { SALEOR_SIGNATURE_HEADER, } from "./const"; -const toStringOrUndefined = (value: string | string[] | undefined) => +const toStringOrUndefined = (value: string | string[] | undefined | null) => value ? value.toString() : undefined; -const toFloatOrNull = (value: string | string[] | undefined) => +const toFloatOrNull = (value: string | string[] | undefined | null) => value ? parseFloat(value.toString()) : null; /** @@ -25,6 +25,15 @@ export const getSaleorHeaders = (headers: { [name: string]: string | string[] | schemaVersion: toFloatOrNull(headers[SALEOR_SCHEMA_VERSION]), }); +export const getSaleorHeadersFetchAPI = (headers: Headers) => ({ + domain: toStringOrUndefined(headers.get(SALEOR_DOMAIN_HEADER)), + authorizationBearer: toStringOrUndefined(headers.get(SALEOR_AUTHORIZATION_BEARER_HEADER)), + signature: toStringOrUndefined(headers.get(SALEOR_SIGNATURE_HEADER)), + event: toStringOrUndefined(headers.get(SALEOR_EVENT_HEADER)), + saleorApiUrl: toStringOrUndefined(headers.get(SALEOR_API_URL_HEADER)), + schemaVersion: toFloatOrNull(headers.get(SALEOR_SCHEMA_VERSION)), +}); + /** * Extracts the app's url from headers from the response. */ @@ -40,3 +49,30 @@ export const getBaseUrl = (headers: { [name: string]: string | string[] | undefi return `${protocol}://${host}`; }; + +export const getBaseUrlFetchAPI = (request: Request) => { + let url: URL | undefined; + try { + url = new URL(request.url); + } catch (e) { + // no-op + } + + const host = request.headers.get("host"); + const xForwardedProto = request.headers.get("x-forwarded-proto"); + + let protocol: string; + if (xForwardedProto) { + const xForwardedForProtocols = xForwardedProto.split(",").map((value) => value.trimStart()); + protocol = xForwardedForProtocols.find((el) => el === "https") || xForwardedForProtocols[0]; + } else if (url) { + // Some providers (e.g. Deno Deploy) + // do not set x-forwarded-for header when handling request + // try to get it from URL + protocol = url.protocol.replace(":", ""); + } else { + protocol = "http"; + } + + return `${protocol}://${host}`; +}; diff --git a/tsup.config.ts b/tsup.config.ts index 720a028e..f6e5aa38 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ "src/app-bridge/index.ts", "src/app-bridge/next/index.ts", "src/handlers/next/index.ts", + "src/handlers/fetch-api/index.ts", + "src/handlers/shared/index.ts", + "src/fetch-middleware/index.ts", "src/middleware/index.ts", "src/settings-manager/index.ts", ],