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 d79d5e37..cb2be8ed 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,14 @@ "@changesets/cli": "2.27.1", "@testing-library/dom": "^8.17.1", "@testing-library/react": "^13.4.0", + "@types/aws-lambda": "^8.10.147", "@types/debug": "^4.1.7", "@types/node": "^18.7.15", "@types/react": "18.0.21", "@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": "4.3.4", "@vitest/coverage-v8": "3.0.4", @@ -75,15 +76,19 @@ "redis": "^4.7.0", "tsm": "^2.2.2", "tsup": "^6.2.3", - "typescript": "4.9.5", + "typescript": "5.4.2", "vi-fetch": "^0.8.0", "vite": "6.0.11 ", + "vite-tsconfig-paths": "^5.1.4", "vitest": "3.0.4" }, "peerDependenciesMeta": { "@vercel/kv": { "optional": true }, + "aws-lambda": { + "optional": true + }, "redis": { "optional": true } @@ -119,6 +124,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", @@ -144,6 +154,26 @@ "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/next-app-router": { + "types": "./handlers/fetch-api/index.d.ts", + "import": "./handlers/fetch-api/index.mjs", + "require": "./handlers/fetch-api/index.js" + }, + "./handlers/actions": { + "types": "./handlers/actions/index.d.ts", + "import": "./handlers/actions/index.mjs", + "require": "./handlers/actions/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 43b995d9..0aef50d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@testing-library/react': specifier: ^13.4.0 version: 13.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/aws-lambda': + specifier: ^8.10.147 + version: 8.10.147 '@types/debug': specifier: ^4.1.7 version: 4.1.7 @@ -56,10 +59,10 @@ importers: 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))(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))(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 @@ -80,7 +83,7 @@ importers: version: 19.0.4(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.6.1(eslint@8.23.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.23.0))(eslint-plugin-react@7.31.6(eslint@8.23.0))(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@8.23.0)(typescript@4.9.5))(eslint@8.23.0)(typescript@4.9.5))(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(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@8.23.0)(typescript@5.4.2))(eslint@8.23.0)(typescript@5.4.2))(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(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) @@ -89,7 +92,7 @@ importers: 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@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + version: 2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(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) @@ -116,7 +119,7 @@ importers: version: 13.0.3(enquirer@2.3.6) next: specifier: ^12.3.0 - version: 12.3.0(@babel/core@7.26.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 12.3.0(@babel/core@7.26.7)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) node-mocks-http: specifier: ^1.11.0 version: 1.11.0 @@ -137,16 +140,19 @@ importers: version: 2.2.2 tsup: specifier: ^6.2.3 - version: 6.2.3(postcss@8.5.1)(typescript@4.9.5) + version: 6.2.3(postcss@8.5.1)(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 vite: specifier: '6.0.11 ' version: 6.0.11(@types/node@18.7.15) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.4.2)(vite@6.0.11(@types/node@18.7.15)) vitest: specifier: 3.0.4 version: 3.0.4(@types/debug@4.1.7)(@types/node@18.7.15)(jsdom@20.0.3) @@ -174,8 +180,8 @@ packages: resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==} engines: {node: '>=6.9.0'} - '@babel/core@7.26.0': - resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + '@babel/core@7.26.7': + resolution: {integrity: sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==} engines: {node: '>=6.9.0'} '@babel/generator@7.26.5': @@ -224,8 +230,8 @@ packages: resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.26.0': - resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + '@babel/helpers@7.26.7': + resolution: {integrity: sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==} engines: {node: '>=6.9.0'} '@babel/highlight@7.18.6': @@ -237,8 +243,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.26.5': - resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + '@babel/parser@7.26.7': + resolution: {integrity: sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==} engines: {node: '>=6.0.0'} hasBin: true @@ -270,16 +276,16 @@ packages: resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.26.5': - resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==} + '@babel/traverse@7.26.7': + resolution: {integrity: sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==} engines: {node: '>=6.9.0'} '@babel/types@7.20.7': resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==} engines: {node: '>=6.9.0'} - '@babel/types@7.26.5': - resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + '@babel/types@7.26.7': + resolution: {integrity: sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -818,6 +824,9 @@ packages: '@types/aria-query@4.2.2': resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==} + '@types/aws-lambda@8.10.147': + resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -886,11 +895,11 @@ packages: typescript: optional: true - '@typescript-eslint/parser@5.36.1': - resolution: {integrity: sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/parser@7.1.1': + 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: @@ -900,6 +909,10 @@ packages: resolution: {integrity: sha512-pGC2SH3/tXdu9IH3ItoqciD3f3RRGCh7hb9zPdN2Drsr341zgd6VbhP5OHQO/reUqihNltfPpMpTNihFMarP2w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/scope-manager@7.1.1': + resolution: {integrity: sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==} + engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/type-utils@5.36.1': resolution: {integrity: sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -914,6 +927,10 @@ packages: resolution: {integrity: sha512-jd93ShpsIk1KgBTx9E+hCSEuLCUFwi9V/urhjOWnOaksGZFbTOxAT47OH2d4NLJnLhkVD+wDbB48BuaycZPLBg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/types@7.1.1': + resolution: {integrity: sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==} + engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/typescript-estree@5.36.1': resolution: {integrity: sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -923,6 +940,15 @@ packages: typescript: optional: true + '@typescript-eslint/typescript-estree@7.1.1': + resolution: {integrity: sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/utils@5.36.1': resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -933,6 +959,10 @@ packages: resolution: {integrity: sha512-ojB9aRyRFzVMN3b5joSYni6FAS10BBSCAfKJhjJAV08t/a95aM6tAhz+O1jF+EtgxktuSO3wJysp2R+Def/IWQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/visitor-keys@7.1.1': + resolution: {integrity: sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==} + engines: {node: ^16.0.0 || >=18.0.0} + '@upstash/redis@1.24.3': resolution: {integrity: sha512-gw6d4IA1biB4eye5ESaXc0zOlVQI94aptsBvVcTghYWu1kRmOrJFoMFEDCa8p5uzluyYAOFCuY2GWLR6O4ZoIw==} @@ -1436,8 +1466,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.87: - resolution: {integrity: sha512-mPFwmEWmRivw2F8x3w3l2m6htAUN97Gy0kwpO++2m9iT1Gt8RCFVUfv9U/sIbHJ6rY4P6/ooqFL/eL7ock+pPg==} + electron-to-chromium@1.5.88: + resolution: {integrity: sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1867,6 +1897,10 @@ packages: resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + 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} + eslint@8.23.0: resolution: {integrity: sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1992,11 +2026,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2547,6 +2576,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3031,6 +3064,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -3321,9 +3359,25 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + ts-api-utils@1.2.1: + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.4: + resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@3.14.1: resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==} @@ -3397,9 +3451,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - 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 unbox-primitive@1.0.2: @@ -3449,6 +3503,14 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@6.0.11: resolution: {integrity: sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3681,18 +3743,18 @@ snapshots: '@babel/compat-data@7.26.5': {} - '@babel/core@7.26.0': + '@babel/core@7.26.7': dependencies: '@ampproject/remapping': 2.2.0 '@babel/code-frame': 7.26.2 '@babel/generator': 7.26.5 '@babel/helper-compilation-targets': 7.26.5 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helpers': 7.26.0 - '@babel/parser': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.7) + '@babel/helpers': 7.26.7 + '@babel/parser': 7.26.7 '@babel/template': 7.25.9 - '@babel/traverse': 7.26.5 - '@babel/types': 7.26.5 + '@babel/traverse': 7.26.7 + '@babel/types': 7.26.7 convert-source-map: 2.0.0 debug: 4.3.4 gensync: 1.0.0-beta.2 @@ -3703,8 +3765,8 @@ snapshots: '@babel/generator@7.26.5': dependencies: - '@babel/parser': 7.26.5 - '@babel/types': 7.26.5 + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 @@ -3719,17 +3781,17 @@ snapshots: '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.26.5 - '@babel/types': 7.26.5 + '@babel/traverse': 7.26.7 + '@babel/types': 7.26.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.26.7 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.5 + '@babel/traverse': 7.26.7 transitivePeerDependencies: - supports-color @@ -3747,10 +3809,10 @@ snapshots: '@babel/helper-validator-option@7.25.9': {} - '@babel/helpers@7.26.0': + '@babel/helpers@7.26.7': dependencies: '@babel/template': 7.25.9 - '@babel/types': 7.26.5 + '@babel/types': 7.26.7 '@babel/highlight@7.18.6': dependencies: @@ -3762,18 +3824,18 @@ snapshots: dependencies: '@babel/types': 7.20.7 - '@babel/parser@7.26.5': + '@babel/parser@7.26.7': dependencies: - '@babel/types': 7.26.5 + '@babel/types': 7.26.7 - '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.26.7 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.26.7 '@babel/helper-plugin-utils': 7.26.5 '@babel/runtime-corejs3@7.18.9': @@ -3792,16 +3854,16 @@ snapshots: '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.5 - '@babel/types': 7.26.5 + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 - '@babel/traverse@7.26.5': + '@babel/traverse@7.26.7': dependencies: '@babel/code-frame': 7.26.2 '@babel/generator': 7.26.5 - '@babel/parser': 7.26.5 + '@babel/parser': 7.26.7 '@babel/template': 7.25.9 - '@babel/types': 7.26.5 + '@babel/types': 7.26.7 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: @@ -3813,7 +3875,7 @@ snapshots: '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 - '@babel/types@7.26.5': + '@babel/types@7.26.7': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 @@ -4314,6 +4376,8 @@ snapshots: '@types/aria-query@4.2.2': {} + '@types/aws-lambda@8.10.147': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.20.7 @@ -4373,33 +4437,34 @@ snapshots: '@types/uuid@8.3.4': {} - '@typescript-eslint/eslint-plugin@5.36.1(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(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))(eslint@8.23.0)(typescript@5.4.2)': 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) + tsutils: 3.21.0(typescript@5.4.2) optionalDependencies: - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color - '@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)': 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 optionalDependencies: - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color @@ -4408,21 +4473,28 @@ snapshots: '@typescript-eslint/types': 5.36.1 '@typescript-eslint/visitor-keys': 5.36.1 - '@typescript-eslint/type-utils@5.36.1(eslint@8.23.0)(typescript@4.9.5)': + '@typescript-eslint/scope-manager@7.1.1': 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/types': 7.1.1 + '@typescript-eslint/visitor-keys': 7.1.1 + + '@typescript-eslint/type-utils@5.36.1(eslint@8.23.0)(typescript@5.4.2)': + dependencies: + '@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) + tsutils: 3.21.0(typescript@5.4.2) optionalDependencies: - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color '@typescript-eslint/types@5.36.1': {} - '@typescript-eslint/typescript-estree@5.36.1(typescript@4.9.5)': + '@typescript-eslint/types@7.1.1': {} + + '@typescript-eslint/typescript-estree@5.36.1(typescript@5.4.2)': dependencies: '@typescript-eslint/types': 5.36.1 '@typescript-eslint/visitor-keys': 5.36.1 @@ -4430,18 +4502,33 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@4.9.5) + tsutils: 3.21.0(typescript@5.4.2) optionalDependencies: - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.36.1(eslint@8.23.0)(typescript@4.9.5)': + '@typescript-eslint/typescript-estree@7.1.1(typescript@5.4.2)': + 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) + optionalDependencies: + typescript: 5.4.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@5.36.1(eslint@8.23.0)(typescript@5.4.2)': dependencies: '@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) @@ -4454,6 +4541,11 @@ snapshots: '@typescript-eslint/types': 5.36.1 eslint-visitor-keys: 3.3.0 + '@typescript-eslint/visitor-keys@7.1.1': + dependencies: + '@typescript-eslint/types': 7.1.1 + eslint-visitor-keys: 3.4.3 + '@upstash/redis@1.24.3': dependencies: crypto-js: 4.2.0 @@ -4464,9 +4556,9 @@ snapshots: '@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@18.7.15))': dependencies: - '@babel/core': 7.26.0 - '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.26.7 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 vite: 6.0.11(@types/node@18.7.15) @@ -4677,7 +4769,7 @@ snapshots: browserslist@4.24.4: dependencies: caniuse-lite: 1.0.30001695 - electron-to-chromium: 1.5.87 + electron-to-chromium: 1.5.88 node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) @@ -4746,7 +4838,7 @@ snapshots: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 ci-info@3.9.0: {} @@ -4938,7 +5030,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.87: {} + electron-to-chromium@1.5.88: {} emoji-regex@8.0.0: {} @@ -5214,24 +5306,24 @@ snapshots: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.23.0 - eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(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 - eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.36.1(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(eslint@8.23.0)(typescript@4.9.5))(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(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@8.23.0)(typescript@5.4.2))(eslint@8.23.0)(typescript@5.4.2))(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(eslint-plugin-import@2.26.0)(eslint@8.23.0): dependencies: - '@typescript-eslint/eslint-plugin': 5.36.1(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(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))(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@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) eslint-config-airbnb@19.0.4(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.6.1(eslint@8.23.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.23.0))(eslint-plugin-react@7.31.6(eslint@8.23.0))(eslint@8.23.0): 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@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(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) @@ -5254,7 +5346,7 @@ snapshots: 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@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0) + eslint-plugin-import: 2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(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 @@ -5263,18 +5355,18 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.7.4(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(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@8.23.0)(typescript@5.4.2))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): dependencies: debug: 3.2.7 optionalDependencies: - '@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) eslint: 8.23.0 eslint-import-resolver-node: 0.3.6 eslint-import-resolver-typescript: 3.5.0(eslint-plugin-import@2.26.0)(eslint@8.23.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.36.1(eslint@8.23.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): + eslint-plugin-import@2.26.0(@typescript-eslint/parser@7.1.1(eslint@8.23.0)(typescript@5.4.2))(eslint-import-resolver-typescript@3.5.0)(eslint@8.23.0): dependencies: array-includes: 3.1.5 array.prototype.flat: 1.3.0 @@ -5282,7 +5374,7 @@ snapshots: 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@8.23.0)(typescript@4.9.5))(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@8.23.0)(typescript@5.4.2))(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 @@ -5291,7 +5383,7 @@ snapshots: resolve: 1.22.1 tsconfig-paths: 3.14.1 optionalDependencies: - '@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) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -5359,6 +5451,8 @@ snapshots: eslint-visitor-keys@3.3.0: {} + eslint-visitor-keys@3.4.3: {} + eslint@8.23.0: dependencies: '@eslint/eslintrc': 1.3.1 @@ -5540,9 +5634,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -6042,13 +6133,13 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.26.5 - '@babel/types': 7.26.5 + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: - semver: 7.5.4 + semver: 7.6.0 map-obj@1.0.1: {} @@ -6101,6 +6192,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -6137,7 +6232,7 @@ snapshots: negotiator@0.6.3: {} - next@12.3.0(@babel/core@7.26.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next@12.3.0(@babel/core@7.26.7)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@next/env': 12.3.0 '@swc/helpers': 0.4.11 @@ -6145,7 +6240,7 @@ snapshots: postcss: 8.4.14 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.0.6(@babel/core@7.26.0)(react@18.2.0) + styled-jsx: 5.0.6(@babel/core@7.26.7)(react@18.2.0) use-sync-external-store: 1.2.0(react@18.2.0) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.0 @@ -6539,7 +6634,7 @@ snapshots: rollup@2.79.0: optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 rollup@4.32.0: dependencies: @@ -6596,6 +6691,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + semver@7.6.0: + dependencies: + lru-cache: 6.0.0 + set-blocking@2.0.0: {} setprototypeof@1.2.0: {} @@ -6759,11 +6858,11 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.0.6(@babel/core@7.26.0)(react@18.2.0): + styled-jsx@5.0.6(@babel/core@7.26.7)(react@18.2.0): dependencies: react: 18.2.0 optionalDependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.26.7 sucrase@3.25.0: dependencies: @@ -6861,8 +6960,16 @@ snapshots: trim-newlines@3.0.1: {} + ts-api-utils@1.2.1(typescript@5.4.2): + dependencies: + typescript: 5.4.2 + ts-interface-checker@0.1.13: {} + tsconfck@3.1.4(typescript@5.4.2): + optionalDependencies: + typescript: 5.4.2 + tsconfig-paths@3.14.1: dependencies: '@types/json5': 0.0.29 @@ -6878,7 +6985,7 @@ snapshots: dependencies: esbuild: 0.14.54 - tsup@6.2.3(postcss@8.5.1)(typescript@4.9.5): + tsup@6.2.3(postcss@8.5.1)(typescript@5.4.2): dependencies: bundle-require: 3.1.0(esbuild@0.15.7) cac: 6.7.14 @@ -6896,15 +7003,15 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.1 - typescript: 4.9.5 + typescript: 5.4.2 transitivePeerDependencies: - supports-color - ts-node - tsutils@3.21.0(typescript@4.9.5): + tsutils@3.21.0(typescript@5.4.2): dependencies: tslib: 1.14.1 - typescript: 4.9.5 + typescript: 5.4.2 tty-table@4.1.6: dependencies: @@ -6939,7 +7046,7 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - typescript@4.9.5: {} + typescript@5.4.2: {} unbox-primitive@1.0.2: dependencies: @@ -7006,6 +7113,17 @@ snapshots: - tsx - yaml + vite-tsconfig-paths@5.1.4(typescript@5.4.2)(vite@6.0.11(@types/node@18.7.15)): + dependencies: + debug: 4.3.4 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.4.2) + optionalDependencies: + vite: 6.0.11(@types/node@18.7.15) + transitivePeerDependencies: + - supports-color + - typescript + vite@6.0.11(@types/node@18.7.15): dependencies: esbuild: 0.24.2 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/actions/index.ts b/src/handlers/actions/index.ts new file mode 100644 index 00000000..c74e02b8 --- /dev/null +++ b/src/handlers/actions/index.ts @@ -0,0 +1 @@ +export * from "./register-action-handler"; diff --git a/src/handlers/actions/manifest-action-handler.ts b/src/handlers/actions/manifest-action-handler.ts new file mode 100644 index 00000000..dda58845 --- /dev/null +++ b/src/handlers/actions/manifest-action-handler.ts @@ -0,0 +1,57 @@ +import { createDebug } from "@/debug"; +import { AppManifest } from "@/types"; + +import { PlatformAdapterMiddleware } from "../shared/adapter-middleware"; +import { + ActionHandlerInterface, + ActionHandlerResult, + PlatformAdapterInterface, +} from "../shared/generic-adapter-use-case-types"; + +const debug = createDebug("create-manifest-handler"); + +export type CreateManifestHandlerOptions = { + manifestFactory(context: { + appBaseUrl: string; + request: T; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; + }): AppManifest | Promise; +}; + +export class ManifestActionHandler implements ActionHandlerInterface { + constructor(private adapter: PlatformAdapterInterface) {} + + private adapterMiddleware = new PlatformAdapterMiddleware(this.adapter); + + async handleAction(options: CreateManifestHandlerOptions): Promise { + const { schemaVersion } = this.adapterMiddleware.getSaleorHeaders(); + const baseURL = this.adapter.getBaseUrl(); + + debug("Received request with schema version \"%s\" and base URL \"%s\"", schemaVersion, baseURL); + + try { + const manifest = await options.manifestFactory({ + appBaseUrl: baseURL, + request: this.adapter.request, + schemaVersion, + }); + + debug("Executed manifest file"); + + return { + status: 200, + bodyType: "json", + body: manifest, + }; + } catch (e) { + debug("Error while resolving manifest: %O", e); + + return { + status: 500, + bodyType: "string", + body: "Error resolving manifest file.", + }; + } + } +} diff --git a/src/handlers/actions/register-action-handler.ts b/src/handlers/actions/register-action-handler.ts new file mode 100644 index 00000000..017938f7 --- /dev/null +++ b/src/handlers/actions/register-action-handler.ts @@ -0,0 +1,506 @@ +/* eslint-disable max-classes-per-file */ +import { APL, 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 { HasAPL } from "@/saleor-app"; + +import { PlatformAdapterMiddleware } from "../shared/adapter-middleware"; +import { + ActionHandlerInterface, + ActionHandlerResult, + PlatformAdapterInterface, + ResultStatusCodes, +} from "../shared/generic-adapter-use-case-types"; +import { validateAllowSaleorUrls } from "../shared/validate-allow-saleor-urls"; + +const debug = createDebug("createAppRegisterHandler"); + +/** Error raised by async handlers passed by + * users in config to Register handler */ +class RegisterCallbackError extends Error { + public status: ResultStatusCodes = 500; + + constructor(errorParams: HookCallbackErrorParams) { + super(errorParams.message); + + if (errorParams.status) { + this.status = errorParams.status; + } + } +} + +export type RegisterHandlerResponseBody = { + success: boolean; + error?: { + code?: string; + message?: string; + }; +}; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"], + statusCode?: ResultStatusCodes +): ActionHandlerResult => ({ + status: statusCode ?? (success ? 200 : 500), + body: { + success, + error, + }, + bodyType: "json", +}); + +export type HookCallbackErrorParams = { + status?: ResultStatusCodes; + message?: string; +}; + +export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never; + +export type AppRegisterHandlerOptions = 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: CallbackErrorHandler; + } + ): Promise; + /** + * Run after all security checks + */ + onRequestVerified?( + request: Request, + 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: Request, + context: { + authData: AuthData; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after APL fails to set AuthData + */ + onAplSetFailed?( + request: Request, + context: { + authData: AuthData; + error: unknown; + respondWithError: CallbackErrorHandler; + } + ): Promise; +}; + +export class RegisterActionHandler implements ActionHandlerInterface { + constructor(private adapter: PlatformAdapterInterface) {} + + private adapterMiddleware = new PlatformAdapterMiddleware(this.adapter); + + private runPreChecks(): ActionHandlerResult | null { + const checksToRun = [ + this.adapterMiddleware.withMethod(["POST"]), + this.adapterMiddleware.withSaleorDomainPresent(), + ]; + + for (const check of checksToRun) { + if (check) { + return check; + } + } + + return null; + } + + async handleAction(config: AppRegisterHandlerOptions): Promise { + debug("Request received"); + + const precheckResult = this.runPreChecks(); + if (precheckResult) { + return precheckResult; + } + + const saleorDomain = this.adapter.getHeader(SALEOR_DOMAIN_HEADER) as string; + const saleorApiUrl = this.adapter.getHeader(SALEOR_API_URL_HEADER) as string; + + const authTokenResult = await this.parseRequestBody(); + + if (!authTokenResult.success) { + return authTokenResult.response; + } + + const { authToken } = authTokenResult; + + const handleOnRequestResult = await this.handleOnRequestStartCallback(config.onRequestStart, { + authToken, + saleorApiUrl, + saleorDomain, + }); + + if (handleOnRequestResult) { + return handleOnRequestResult; + } + + if (!saleorApiUrl) { + // TODO: We should strictly require `saleorApiUrl` instead of + // relying on `saleor-domain` that is deprecated + debug("saleorApiUrl doesn't exist in headers"); + } + + const saleorApiUrlValidationResult = this.handleSaleorApiUrlValidation({ + saleorApiUrl, + allowedSaleorUrls: config.allowedSaleorUrls, + }); + + if (saleorApiUrlValidationResult) { + return saleorApiUrlValidationResult; + } + + const aplCheckResult = await this.checkAplIsConfigured(config.apl); + + if (aplCheckResult) { + return aplCheckResult; + } + + const getAppIdResult = await this.getAppIdAndHandleMissingAppId({ + saleorApiUrl, + token: authToken, + }); + + if (!getAppIdResult.success) { + return getAppIdResult.responseBody; + } + + const { appId } = getAppIdResult; + + const getJwksResult = await this.getJwksAndHandleMissingJwks({ saleorApiUrl }); + + if (!getJwksResult.success) { + return getJwksResult.responseBody; + } + + const { jwks } = getJwksResult; + + const authData = { + domain: saleorDomain, + token: authToken, + saleorApiUrl, + appId, + jwks, + }; + + const onRequestVerifiedErrorResponse = await this.handleOnRequestVerifiedCallback( + config.onRequestVerified, + authData + ); + + if (onRequestVerifiedErrorResponse) { + return onRequestVerifiedErrorResponse; + } + + const aplSaveResponse = await this.saveAplAuthData({ + apl: config.apl, + authData, + onAplSetFailed: config.onAplSetFailed, + onAuthAplSaved: config.onAuthAplSaved, + }); + + return aplSaveResponse; + } + + private async parseRequestBody(): Promise< + | { success: false; response: ActionHandlerResult; authToken?: never } + | { + success: true; + authToken: string; + response?: never; + } + > { + let body: { auth_token: string }; + try { + body = (await this.adapter.getBody()) as { auth_token: string }; + } catch (err) { + return { + success: false, + response: { + status: 400, + body: "Invalid request json.", + bodyType: "string", + }, + }; + } + + const authToken = body?.auth_token; + + if (!authToken) { + debug("Found missing authToken param"); + + return { + success: false, + response: { + status: 400, + body: "Missing auth token.", + bodyType: "string", + }, + }; + } + + return { + success: true, + authToken, + }; + } + + private async handleOnRequestStartCallback( + onRequestStart: AppRegisterHandlerOptions["onRequestStart"], + { + authToken, + saleorApiUrl, + saleorDomain, + }: { authToken: string; saleorApiUrl: string; saleorDomain: string } + ) { + if (onRequestStart) { + debug("Calling \"onRequestStart\" hook"); + + try { + await onRequestStart(this.adapter.request, { + authToken, + saleorApiUrl, + saleorDomain, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestStart\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + + return null; + } + + private handleSaleorApiUrlValidation({ + saleorApiUrl, + allowedSaleorUrls, + }: { + saleorApiUrl: string; + allowedSaleorUrls: AppRegisterHandlerOptions["allowedSaleorUrls"]; + }) { + if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { + debug( + "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", + saleorApiUrl + ); + + return createRegisterHandlerResponseBody( + false, + { + code: "SALEOR_URL_PROHIBITED", + message: "This app expects to be installed only in allowed Saleor instances", + }, + 403 + ); + } + + return null; + } + + private async checkAplIsConfigured(apl: AppRegisterHandlerOptions["apl"]) { + const { configured: aplConfigured } = await apl.isConfigured(); + + if (!aplConfigured) { + debug("The APL has not been configured"); + + return createRegisterHandlerResponseBody( + false, + { + code: "APL_NOT_CONFIGURED", + message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", + }, + 503 + ); + } + + return null; + } + + private async getAppIdAndHandleMissingAppId({ + saleorApiUrl, + token, + }: { + saleorApiUrl: string; + token: string; + }): Promise< + | { + success: false; + responseBody: ActionHandlerResult; + } + | { success: true; appId: string } + > { + // Try to get App ID from the API, to confirm that communication can be established + const appId = await getAppId({ saleorApiUrl, token }); + + if (!appId) { + const responseBody = 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}`, + }, + 401 + ); + + return { success: false, responseBody }; + } + + return { success: true, appId }; + } + + private async getJwksAndHandleMissingJwks({ saleorApiUrl }: { saleorApiUrl: string }): Promise< + | { + success: false; + responseBody: ActionHandlerResult; + } + | { success: true; jwks: string } + > { + // Fetch the JWKS which will be used during webhook validation + const jwks = await fetchRemoteJwks(saleorApiUrl); + if (!jwks) { + const responseBody = createRegisterHandlerResponseBody( + false, + { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }, + 401 + ); + + return { success: false, responseBody }; + } + return { success: true, jwks }; + } + + private async handleOnRequestVerifiedCallback( + onRequestVerified: AppRegisterHandlerOptions["onRequestVerified"], + authData: AuthData + ) { + if (onRequestVerified) { + debug("Calling \"onRequestVerified\" hook"); + + try { + await onRequestVerified(this.adapter.request, { + authData, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestVerified\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + + return null; + } + + private async saveAplAuthData({ + apl, + onAplSetFailed, + onAuthAplSaved, + authData, + }: { + apl: APL; + onAplSetFailed: AppRegisterHandlerOptions["onAplSetFailed"]; + onAuthAplSaved: AppRegisterHandlerOptions["onAuthAplSaved"]; + authData: AuthData; + }) { + try { + await apl.set(authData); + + if (onAuthAplSaved) { + debug("Calling \"onAuthAplSaved\" hook"); + + try { + await onAuthAplSaved(this.adapter.request, { + authData, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onAuthAplSaved\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + } catch (aplError: unknown) { + debug("There was an error during saving the auth data"); + + if (onAplSetFailed) { + debug("Calling \"onAuthAplFailed\" hook"); + + try { + await onAplSetFailed(this.adapter.request, { + authData, + error: aplError, + respondWithError: this.createCallbackError, + }); + } catch (hookError: RegisterCallbackError | unknown) { + debug("\"onAuthAplFailed\" hook thrown error: %o", hookError); + + return this.handleHookError(hookError); + } + } + + return createRegisterHandlerResponseBody(false, { + message: "Registration failed: could not save the auth data.", + }); + } + + debug("Register complete"); + return createRegisterHandlerResponseBody(true); + } + + /** Callbacks declared by users in configuration can throw an error + * It is caught here and converted into a response */ + private handleHookError(e: RegisterCallbackError | unknown): ActionHandlerResult { + if (e instanceof RegisterCallbackError) { + return createRegisterHandlerResponseBody( + false, + { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: e.message, + }, + e.status + ); + } + return { + status: 500, + body: "Error during app installation", + bodyType: "string", + }; + } + + private createCallbackError: CallbackErrorHandler = (params: HookCallbackErrorParams) => { + throw new RegisterCallbackError(params); + }; +} diff --git a/src/handlers/next/create-app-register-handler.ts b/src/handlers/next/create-app-register-handler.ts deleted file mode 100644 index 1e0da6de..00000000 --- a/src/handlers/next/create-app-register-handler.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { Handler, Request } from "retes"; -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"; - -const debug = createDebug("createAppRegisterHandler"); - -type HookCallbackErrorParams = { - status?: number; - message?: string; -}; - -class RegisterCallbackError extends Error { - public status = 500; - - constructor(errorParams: HookCallbackErrorParams) { - super(errorParams.message); - - if (errorParams.status) { - this.status = errorParams.status; - } - } -} - -const createCallbackError = (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 new Response( - createRegisterHandlerResponseBody(false, { - code: "REGISTER_HANDLER_HOOK_ERROR", - message: e.message, - }), - { status: e.status } - ); - } - 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; -}; - -/** - * 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: Handler = async (request) => { - debug("Request received"); - - const authToken = request.params.auth_token; - const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string; - const saleorApiUrl = request.headers[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 doesnt exist in headers"); - } - - if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { - debug( - "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", - saleorApiUrl - ); - - return Response.Forbidden( - createRegisterHandlerResponseBody(false, { - code: "SALEOR_URL_PROHIBITED", - message: "This app expects to be installed only in allowed Saleor instances", - }) - ); - } - - const { configured: aplConfigured } = await apl.isConfigured(); - - if (!aplConfigured) { - debug("The APL has not been configured"); - - return new Response( - 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 new Response( - 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 new Response( - 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.InternalServerError( - createRegisterHandlerResponseBody(false, { - message: "Registration failed: could not save the auth data.", - }) - ); - } - - debug("Register complete"); - - return Response.OK(createRegisterHandlerResponseBody(true)); - }; - - return toNextHandler([ - withMethod("POST"), - withSaleorDomainPresent, - withAuthTokenRequired, - baseHandler, - ]); -}; diff --git a/src/handlers/next/create-manifest-handler.ts b/src/handlers/next/create-manifest-handler.ts deleted file mode 100644 index aceedd6c..00000000 --- a/src/handlers/next/create-manifest-handler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextApiHandler, NextApiRequest } from "next"; - -import { createDebug } from "../../debug"; -import { getBaseUrl, getSaleorHeaders } from "../../headers"; -import { AppManifest } from "../../types"; - -export type CreateManifestHandlerOptions = { - manifestFactory(context: { - appBaseUrl: string; - request: NextApiRequest; - /** For Saleor < 3.15 it will be null. */ - schemaVersion: number | null; - }): AppManifest | Promise; -}; - -const debug = createDebug("create-manifest-handler"); - -/** - * Creates API handler for Next.js. Helps with Manifest creation, hides - * implementation details if possible - * In the future this will be extracted to separate sdk/next package - */ -export const createManifestHandler = - (options: CreateManifestHandlerOptions): NextApiHandler => - async (request, response) => { - const { schemaVersion } = getSaleorHeaders(request.headers); - const baseURL = getBaseUrl(request.headers); - - debug("Received request with schema version \"%s\" and base URL \"%s\"", schemaVersion, baseURL); - - try { - const manifest = await options.manifestFactory({ - appBaseUrl: baseURL, - request, - schemaVersion, - }); - - debug("Executed manifest file"); - - return response.status(200).json(manifest); - } catch (e) { - debug("Error while resolving manifest: %O", e); - - return response.status(500).json({ - message: "Error resolving manifest file", - }); - } - }; diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts deleted file mode 100644 index 44027670..00000000 --- a/src/handlers/next/create-protected-handler.ts +++ /dev/null @@ -1,64 +0,0 @@ -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"; - -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, - ctx: ProtectedHandlerContext -) => unknown | Promise; - -/** - * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. - * Also provides additional `context` object containing request properties. - */ -export const createProtectedHandler = - ( - handlerFn: NextProtectedApiHandler, - apl: APL, - requiredPermissions?: Permission[] - ): NextApiHandler => - (req, res) => { - debug("Protected handler called"); - processSaleorProtectedHandler({ - req, - apl, - requiredPermissions, - }) - .then(async (context) => { - debug("Incoming request validated. Call handlerFn"); - return handlerFn(req, res, context); - }) - .catch((e) => { - debug("Unexpected error during processing the request"); - - if (e instanceof ProtectedHandlerError) { - debug(`Validation error: ${e.message}`); - res.status(ProtectedHandlerErrorCodeMap[e.errorType] || 400).end(); - return; - } - debug("Unexpected error: %O", e); - res.status(500).end(); - }); - }; diff --git a/src/handlers/next/protected-handler-context.ts b/src/handlers/next/protected-handler-context.ts deleted file mode 100644 index 71bb42d3..00000000 --- a/src/handlers/next/protected-handler-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AuthData } from "../../APL"; -import { TokenUserPayload } from "../../util/extract-user-from-jwt"; - -export type ProtectedHandlerContext = { - baseUrl: string; - authData: AuthData; - user: TokenUserPayload; -}; diff --git a/src/handlers/next/readme.md b/src/handlers/next/readme.md deleted file mode 100644 index e6cfad4f..00000000 --- a/src/handlers/next/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -Handlers with adapters to Next.js - -TODO Extract to separate package diff --git a/src/handlers/next/saleor-webhooks/saleor-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-webhook.ts deleted file mode 100644 index 9a0d7632..00000000 --- a/src/handlers/next/saleor-webhooks/saleor-webhook.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { ASTNode } from "graphql"; -import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; - -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"; - -const debug = createDebug("SaleorWebhook"); - -export interface WebhookConfig { - name?: string; - webhookPath: string; - event: Event; - isActive?: boolean; - apl: APL; - onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; - formatErrorResponse?( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ): Promise<{ - code: number; - body: object | string; - }>; - query: string | ASTNode; - /** - * @deprecated will be removed in 0.35.0, use query field instead - */ - subscriptionQueryAst?: ASTNode; -} - -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, -}; - -export type NextWebhookApiHandler = ( - req: NextApiRequest, - res: NextApiResponse, - ctx: WebhookContext & TExtras -) => unknown | Promise; - -export abstract class SaleorWebhook< - 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: NextWebhookApiHandler): NextApiHandler { - return async (req, res) => { - debug(`Handler for webhook ${this.name} called`); - - await processSaleorWebhook({ - req, - apl: this.apl, - allowedEvent: this.event, - }) - .then(async (context) => { - debug("Incoming request validated. Call handlerFn"); - - return handlerFn(req, res, { ...(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, res); - } - - if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); - - res.status(code).send(body); - - return; - } - - res.status(WebhookErrorCodeMap[e.errorType] || 400).send({ - error: { - type: e.errorType, - message: e.message, - }, - }); - return; - } - debug("Unexpected error: %O", e); - - if (this.onError) { - this.onError(e, req, res); - } - - if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); - - res.status(code).send(body); - - return; - } - - res.status(500).end(); - }); - }; - } -} diff --git a/src/handlers/platforms/aws-lambda/create-app-register.ts b/src/handlers/platforms/aws-lambda/create-app-register.ts new file mode 100644 index 00000000..e30b54bf --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-app-register.ts @@ -0,0 +1,44 @@ +import { + RegisterActionHandler, + RegisterHandlerResponseBody, +} from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; + +import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"] +): RegisterHandlerResponseBody => ({ + success, + error, +}); + +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; + +/** + * Returns API route handler for AWS Lambda HTTP triggered events + * (created by Amazon API Gateway, Lambda Function URL) + * that use signature: (event: APIGatewayProxyEventV2, context: Context) => APIGatewayProxyResultV2 + * + * Handler is for register endpoint that is called by Saleor when installing the app + * + * It verifies the request and stores `app_token` from Saleor + * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) + * + * **Recommended path**: `/api/register` + * (configured in manifest handler) + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} + * @see {@link https://www.npmjs.com/package/@types/aws-lambda} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const useCase = new RegisterActionHandler(adapter); + const result = await useCase.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/aws-lambda/create-manifest-handler.ts b/src/handlers/platforms/aws-lambda/create-manifest-handler.ts new file mode 100644 index 00000000..6435a5fc --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-manifest-handler.ts @@ -0,0 +1,17 @@ +import { + CreateManifestHandlerOptions as GenericHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; + +import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "./platform-adapter"; + +export type CreateManifestHandlerOptions = GenericHandlerOptions; + +export const createManifestHandler = + (config: CreateManifestHandlerOptions): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/aws-lambda/create-protected-handler.ts b/src/handlers/platforms/aws-lambda/create-protected-handler.ts new file mode 100644 index 00000000..2de47291 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/create-protected-handler.ts @@ -0,0 +1,49 @@ +import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from "aws-lambda"; + +import { APL } from "@/APL"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; + +import { AwsLambdaAdapter, AWSLambdaHandler } from "./platform-adapter"; + +export type AwsLambdaProtectedHandler = ( + event: APIGatewayProxyEventV2, + context: Context, + saleorContext: ProtectedHandlerContext +) => Promise; + +/** + * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. + * Also provides additional `saleorContext` object containing request properties. + */ +export const createProtectedHandler = + ( + handlerFn: AwsLambdaProtectedHandler, + apl: APL, + requiredPermissions?: Permission[] + ): AWSLambdaHandler => + async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ + apl, + requiredPermissions, + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } + + const saleorContext = validationResult.value; + try { + return await handlerFn(event, context, saleorContext); + } catch (err) { + return { + statusCode: 500, + body: "Unexpected Server Error", + }; + } + }; diff --git a/src/handlers/platforms/aws-lambda/index.ts b/src/handlers/platforms/aws-lambda/index.ts new file mode 100644 index 00000000..ba7cba5f --- /dev/null +++ b/src/handlers/platforms/aws-lambda/index.ts @@ -0,0 +1,4 @@ +export * from "./create-app-register"; +export * from "./create-manifest-handler"; +export * from "./create-protected-handler"; +export * from "./platform-adapter"; diff --git a/src/handlers/platforms/aws-lambda/platform-adapter.ts b/src/handlers/platforms/aws-lambda/platform-adapter.ts new file mode 100644 index 00000000..4a58f376 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/platform-adapter.ts @@ -0,0 +1,81 @@ +import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from "aws-lambda"; + +import { + ActionHandlerResult, + HTTPMethod, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type AwsLambdaHandlerInput = APIGatewayProxyEventV2; +export type AWSLambdaHandler = ( + event: APIGatewayProxyEventV2, + context: Context +) => Promise; + +export class AwsLambdaAdapter implements PlatformAdapterInterface { + public request: AwsLambdaHandlerInput; + + constructor(private event: APIGatewayProxyEventV2, private context: Context) { + this.request = event; + } + + getHeader(name: string): string | null { + return this.request.headers[name] || null; + } + + async getBody(): Promise { + try { + return JSON.parse(this.request.body || "{}"); + } catch (err) { + return null; + } + } + + async getRawBody(): Promise { + const { body } = this.request; + if (!body) { + return null; + } + + return body; + } + + getBaseUrl(): string { + const xForwardedProto = this.getHeader("X-Forwarded-Proto") || "https"; + const host = this.getHeader("Host"); + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + // API Gateway splits deployment into multiple stages which are + // included in the API url (e.g. /dev or /prod) + // More details: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + const { stage } = this.event.requestContext; + + if (stage) { + return `${protocol}://${host}/${stage}`; + } + + return `${protocol}://${host}`; + } + + get method(): HTTPMethod { + return this.event.requestContext.http.method as HTTPMethod; + } + + async send(result: ActionHandlerResult): Promise { + const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; + + return { + statusCode: result.status, + headers: { + "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", + }, + body, + }; + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 00000000..a567d1b5 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,50 @@ +import { ASTNode } from "graphql"; + +import { AsyncWebhookEventType } from "@/types"; + +import { AWSLambdaHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, SaleorWebhookHandler, 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): AWSLambdaHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 00000000..ad43f8bd --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,38 @@ +import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@/types"; + +import { AWSLambdaHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, SaleorWebhookHandler, 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; + } + > + ): AWSLambdaHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..2829d8d6 --- /dev/null +++ b/src/handlers/platforms/aws-lambda/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,49 @@ +import { APIGatewayProxyResultV2, Context } from "aws-lambda"; + +import { createDebug } from "@/debug"; +import { + GenericSaleorWebhook, + GenericWebhookConfig, +} from "@/handlers/shared/generic-saleor-webhook"; +import { WebhookContext } from "@/handlers/shared/process-saleor-webhook"; +import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; + +import { AwsLambdaAdapter, AWSLambdaHandler, AwsLambdaHandlerInput } from "../platform-adapter"; + +const debug = createDebug("SaleorWebhook"); + +export type WebhookConfig = + GenericWebhookConfig; + +/** Function type provided by consumer in `SaleorWebApiWebhook.createHandler` */ +export type SaleorWebhookHandler = ( + event: AwsLambdaHandlerInput, + context: Context, + ctx: WebhookContext & TExtras +) => Promise | APIGatewayProxyResultV2; + +export abstract class SaleorWebApiWebhook< + TPayload = unknown, + TExtras extends Record = {} +> extends GenericSaleorWebhook { + /** + * 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): AWSLambdaHandler { + return async (event, context) => { + const adapter = new AwsLambdaAdapter(event, context); + const prepareRequestResult = await super.prepareRequest(adapter); + + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } + + debug("Incoming request validated. Call handlerFn"); + return handlerFn(event, context, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); + }; + } +} diff --git a/src/handlers/platforms/fetch-api/create-app-register-handler.ts b/src/handlers/platforms/fetch-api/create-app-register-handler.ts new file mode 100644 index 00000000..abffea2e --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-app-register-handler.ts @@ -0,0 +1,47 @@ +import { + RegisterActionHandler, + RegisterHandlerResponseBody, +} from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"] +): RegisterHandlerResponseBody => ({ + success, + error, +}); + +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; + +/** + * Returns API route handler for Web API compatible request handlers + * (examples: Next.js app router, hono, deno, etc.) + * that use signature: (req: Request) => Response + * where Request and Response are Fetch API objects + * + * Handler is for register endpoint that is called by Saleor when installing the app + * + * It verifies the request and stores `app_token` from Saleor + * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) + * + * **Recommended path**: `/api/register` + * (configured in manifest handler) + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): WebApiHandler => + async (req) => { + const adapter = new WebApiAdapter(req); + const useCase = new RegisterActionHandler(adapter); + const result = await useCase.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/fetch-api/create-manifest-handler.ts b/src/handlers/platforms/fetch-api/create-manifest-handler.ts new file mode 100644 index 00000000..2c463798 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-manifest-handler.ts @@ -0,0 +1,17 @@ +import { + CreateManifestHandlerOptions as GenericHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "./platform-adapter"; + +export type CreateManifestHandlerOptions = GenericHandlerOptions; + +export const createManifestHandler = + (config: CreateManifestHandlerOptions): WebApiHandler => + async (request: Request) => { + const adapter = new WebApiAdapter(request); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/fetch-api/create-protected-handler.ts b/src/handlers/platforms/fetch-api/create-protected-handler.ts new file mode 100644 index 00000000..052b99f5 --- /dev/null +++ b/src/handlers/platforms/fetch-api/create-protected-handler.ts @@ -0,0 +1,39 @@ +import { APL } from "@/APL"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; + +import { WebApiAdapter } from "./platform-adapter"; + +export type WebApiProtectedHandler = ( + request: Request, + ctx: ProtectedHandlerContext +) => Response | Promise; + +export const createProtectedHandler = + ( + handlerFn: WebApiProtectedHandler, + apl: APL, + requiredPermissions?: Permission[] + ): WebApiProtectedHandler => + async (request) => { + const adapter = new WebApiAdapter(request); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ + apl, + requiredPermissions, + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } + + const context = validationResult.value; + try { + return await handlerFn(request, context); + } catch (err) { + return new Response("Unexpected server error", { status: 500 }); + } + }; diff --git a/src/handlers/platforms/fetch-api/index.ts b/src/handlers/platforms/fetch-api/index.ts new file mode 100644 index 00000000..b1ad0cd4 --- /dev/null +++ b/src/handlers/platforms/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 "./platform-adapter"; +export * from "./saleor-webhooks/saleor-async-webhook"; +export * from "./saleor-webhooks/saleor-sync-webhook"; diff --git a/src/handlers/platforms/fetch-api/platform-adapter.ts b/src/handlers/platforms/fetch-api/platform-adapter.ts new file mode 100644 index 00000000..94c22325 --- /dev/null +++ b/src/handlers/platforms/fetch-api/platform-adapter.ts @@ -0,0 +1,71 @@ +import { + ActionHandlerResult, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type WebApiHandlerInput = Request; +export type WebApiHandler = (req: Request) => Response | Promise; + +export class WebApiAdapter implements PlatformAdapterInterface { + constructor(public request: Request) {} + + getHeader(name: string) { + return this.request.headers.get(name); + } + + async getBody() { + const request = this.request.clone(); + try { + return await request.json(); + } catch (err) { + return null; + } + } + + async getRawBody() { + const request = this.request.clone(); + return request.text(); + } + + getBaseUrl(): string { + let url: URL | undefined; + try { + url = new URL(this.request.url); + } catch (e) { + // no-op + } + + const host = this.request.headers.get("host"); + const xForwardedProto = this.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}`; + } + + get method() { + return this.request.method as "POST" | "GET"; + } + + async send(result: ActionHandlerResult): Promise { + const body = result.bodyType === "json" ? JSON.stringify(result.body) : result.body; + + return new Response(body, { + status: result.status, + headers: { + "Content-Type": result.bodyType === "json" ? "application/json" : "text/plain", + }, + }); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 00000000..8c7528d7 --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,50 @@ +import { ASTNode } from "graphql"; + +import { AsyncWebhookEventType } from "@/types"; + +import { WebApiHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, SaleorWebhookHandler, 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): WebApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 00000000..d4b34909 --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,38 @@ +import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@/types"; + +import { WebApiHandler } from "../platform-adapter"; +import { SaleorWebApiWebhook, SaleorWebhookHandler, 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; + } + > + ): WebApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..ae32ebbe --- /dev/null +++ b/src/handlers/platforms/fetch-api/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,42 @@ +import { createDebug } from "@/debug"; +import { + GenericSaleorWebhook, + GenericWebhookConfig, +} from "@/handlers/shared/generic-saleor-webhook"; +import { WebhookContext } from "@/handlers/shared/process-saleor-webhook"; +import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; + +import { WebApiAdapter, WebApiHandler, WebApiHandlerInput } from "../platform-adapter"; + +const debug = createDebug("SaleorWebhook"); + +export type WebhookConfig = + GenericWebhookConfig; + +/** 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 = {} +> extends GenericSaleorWebhook { + createHandler(handlerFn: SaleorWebhookHandler): WebApiHandler { + return async (req) => { + const adapter = new WebApiAdapter(req); + const prepareRequestResult = await super.prepareRequest(adapter); + + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } + + debug("Incoming request validated. Call handlerFn"); + return handlerFn(req, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); + }; + } +} diff --git a/src/handlers/next/create-app-register-handler.test.ts b/src/handlers/platforms/next/create-app-register-handler.test.ts similarity index 93% rename from src/handlers/next/create-app-register-handler.test.ts rename to src/handlers/platforms/next/create-app-register-handler.test.ts index 709ed0f9..4cff67d4 100644 --- a/src/handlers/next/create-app-register-handler.test.ts +++ b/src/handlers/platforms/next/create-app-register-handler.test.ts @@ -1,20 +1,20 @@ import { createMocks } from "node-mocks-http"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; -import { APL, AuthData } from "../../APL"; -import { MockAPL } from "../../test-utils/mock-apl"; +import { APL, AuthData } from "@/APL"; +import * as fetchRemoteJwksModule from "@/fetch-remote-jwks"; +import * as getAppIdModule from "@/get-app-id"; +import { MockAPL } from "@/test-utils/mock-apl"; + import { createAppRegisterHandler } from "./create-app-register-handler"; const mockJwksValue = "{}"; const mockAppId = "42"; -vi.mock("../../get-app-id", () => ({ - getAppId: vi.fn().mockResolvedValue("42"), // can't use var reference, due to hoisting -})); - -vi.mock("../../fetch-remote-jwks", () => ({ - fetchRemoteJwks: vi.fn().mockResolvedValue("{}"), // can't use var reference, due to hoisting -})); +// Cannot use vi.mock on module, due to issues with alias resolution +// in vitest vs TypeScript: https://github.com/vitest-dev/vitest/issues/3105 +vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("{}"); +vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue("42"); describe("create-app-register-handler", () => { let mockApl: APL; @@ -85,7 +85,7 @@ describe("create-app-register-handler", () => { await handler(req, res); expect(res._getStatusCode()).toBe(403); - expect(res._getData().success).toBe(false); + expect(res._getJSONData().success).toBe(false); }); describe("Callback hooks", () => { @@ -246,7 +246,7 @@ describe("create-app-register-handler", () => { await handler(req, res); expect(res._getStatusCode()).toBe(401); - expect(res._getData()).toEqual({ + expect(res._getJSONData()).toEqual({ success: false, error: { code: "REGISTER_HANDLER_HOOK_ERROR", diff --git a/src/handlers/platforms/next/create-app-register-handler.ts b/src/handlers/platforms/next/create-app-register-handler.ts new file mode 100644 index 00000000..38adfad7 --- /dev/null +++ b/src/handlers/platforms/next/create-app-register-handler.ts @@ -0,0 +1,44 @@ +import { + RegisterActionHandler, + RegisterHandlerResponseBody, +} from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; + +import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; + +// Re-export types for backwards compatibility + +export type { RegisterHandlerResponseBody }; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"] +): RegisterHandlerResponseBody => ({ + success, + error, +}); + +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; + +/** + * Returns API route handler for **Next.js pages router** + * for register endpoint that is called by Saleor when installing the app + * + * It verifies the request and stores `app_token` from Saleor + * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) + * + * **Recommended path**: `/api/register` + * (configured in manifest handler) + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): NextJsHandler => + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionHandler = new RegisterActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); + }; diff --git a/src/handlers/next/create-manifest-handler.test.ts b/src/handlers/platforms/next/create-manifest-handler.test.ts similarity index 96% rename from src/handlers/next/create-manifest-handler.test.ts rename to src/handlers/platforms/next/create-manifest-handler.test.ts index f9caa04b..5fe18733 100644 --- a/src/handlers/next/create-manifest-handler.test.ts +++ b/src/handlers/platforms/next/create-manifest-handler.test.ts @@ -1,7 +1,8 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, it } from "vitest"; -import { AppManifest } from "../../types"; +import { AppManifest } from "@/types"; + import { createManifestHandler } from "./create-manifest-handler"; describe("createManifestHandler", () => { diff --git a/src/handlers/platforms/next/create-manifest-handler.ts b/src/handlers/platforms/next/create-manifest-handler.ts new file mode 100644 index 00000000..67cd4cff --- /dev/null +++ b/src/handlers/platforms/next/create-manifest-handler.ts @@ -0,0 +1,23 @@ +import { + CreateManifestHandlerOptions as GenericCreateManifestHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; + +import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; + +export type CreateManifestHandlerOptions = GenericCreateManifestHandlerOptions; + +/** + * Creates API handler for Next.js page router. + * + * elps with Manifest creation, hides + * implementation details if possible + */ +export const createManifestHandler = + (options: CreateManifestHandlerOptions): NextJsHandler => + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(options); + return adapter.send(result); + }; diff --git a/src/handlers/platforms/next/create-protected-handler.ts b/src/handlers/platforms/next/create-protected-handler.ts new file mode 100644 index 00000000..efeccaa3 --- /dev/null +++ b/src/handlers/platforms/next/create-protected-handler.ts @@ -0,0 +1,46 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +import { APL } from "@/APL"; +import { + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; + +import { NextJsAdapter } from "./platform-adapter"; + +export type NextProtectedApiHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: ProtectedHandlerContext +) => unknown | Promise; + +/** + * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. + * Also provides additional `context` object containing request properties. + */ +export const createProtectedHandler = + ( + handlerFn: NextProtectedApiHandler, + apl: APL, + requiredPermissions?: Permission[] + ): NextApiHandler => + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ + apl, + requiredPermissions, + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } + + const context = validationResult.value; + try { + return handlerFn(req, res, context); + } catch (err) { + return res.status(500).end(); + } + }; diff --git a/src/handlers/next/index.ts b/src/handlers/platforms/next/index.ts similarity index 70% rename from src/handlers/next/index.ts rename to src/handlers/platforms/next/index.ts index 23ddd049..06198c61 100644 --- a/src/handlers/next/index.ts +++ b/src/handlers/platforms/next/index.ts @@ -1,9 +1,10 @@ +// Re-export to avoid breaking changes +export * from "../../shared/protected-action-validator"; +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/platforms/next/platform-adapter.ts b/src/handlers/platforms/next/platform-adapter.ts new file mode 100644 index 00000000..44eb53e7 --- /dev/null +++ b/src/handlers/platforms/next/platform-adapter.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import getRawBody from "raw-body"; + +import { + ActionHandlerResult, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type NextJsHandlerInput = NextApiRequest; +export type NextJsHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; + +export class NextJsAdapter implements PlatformAdapterInterface { + readonly type = "next" as const; + + constructor(public request: NextApiRequest, private res: NextApiResponse) {} + + getHeader(name: string) { + const header = this.request.headers[name]; + return Array.isArray(header) ? header.join(", ") : header ?? null; + } + + getBody(): Promise { + return Promise.resolve(this.request.body); + } + + async getRawBody(): Promise { + return ( + await getRawBody(this.request, { + length: this.request.headers["content-length"], + }) + ).toString(); + } + + getBaseUrl(): string { + const { host, "x-forwarded-proto": xForwardedProto = "http" } = this.request.headers; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + return `${protocol}://${host}`; + } + + get method() { + return this.request.method as "POST" | "GET"; + } + + async send(result: ActionHandlerResult): Promise { + if (result.bodyType === "json") { + this.res.status(result.status).json(result.body); + } else { + this.res.status(result.status).send(result.body); + } + } +} diff --git a/src/handlers/next/process-protected-handler.test.ts b/src/handlers/platforms/next/process-protected-handler.test.ts similarity index 91% rename from src/handlers/next/process-protected-handler.test.ts rename to src/handlers/platforms/next/process-protected-handler.test.ts index 69c3e221..fd2ddced 100644 --- a/src/handlers/next/process-protected-handler.test.ts +++ b/src/handlers/platforms/next/process-protected-handler.test.ts @@ -2,9 +2,12 @@ import { NextApiRequest } from "next/types"; import { createMocks } from "node-mocks-http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getAppId } from "../../get-app-id"; -import { MockAPL } from "../../test-utils/mock-apl"; -import { verifyJWT } from "../../verify-jwt"; +import * as getAppIdModule from "@/get-app-id"; +import { getAppId } from "@/get-app-id"; +import { MockAPL } from "@/test-utils/mock-apl"; +import * as verifyJWTModule from "@/verify-jwt"; +import { verifyJWT } from "@/verify-jwt"; + import { processSaleorProtectedHandler } from "./process-protected-handler"; const validToken = @@ -12,15 +15,8 @@ const validToken = const validAppId = "QXBwOjI3NQ=="; -vi.mock("./../../get-app-id", () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getAppId: vi.fn(), -})); - -vi.mock("./../../verify-jwt", () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - verifyJWT: vi.fn(), -})); +vi.spyOn(getAppIdModule, "getAppId"); +vi.spyOn(verifyJWTModule, "verifyJWT"); describe("processSaleorProtectedHandler", () => { let mockRequest: NextApiRequest; diff --git a/src/handlers/next/process-protected-handler.ts b/src/handlers/platforms/next/process-protected-handler.ts similarity index 85% rename from src/handlers/next/process-protected-handler.ts rename to src/handlers/platforms/next/process-protected-handler.ts index f3f86e3c..9476e1ab 100644 --- a/src/handlers/next/process-protected-handler.ts +++ b/src/handlers/platforms/next/process-protected-handler.ts @@ -1,27 +1,18 @@ import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { NextApiRequest } from "next"; -import { APL } from "../../APL"; -import { createDebug } from "../../debug"; -import { getBaseUrl, getSaleorHeaders } 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 "./protected-handler-context"; +import { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { ProtectedHandlerContext } from "@/handlers/shared/protected-action-validator"; +import { SaleorProtectedHandlerError } from "@/handlers/shared/protected-handler"; +import { getBaseUrl, getSaleorHeaders } from "@/headers"; +import { getOtelTracer } from "@/open-telemetry"; +import { Permission } from "@/types"; +import { extractUserFromJwt } from "@/util/extract-user-from-jwt"; +import { verifyJWT } from "@/verify-jwt"; const debug = createDebug("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"; @@ -49,6 +40,8 @@ type ProcessAsyncSaleorProtectedHandler = ( * In case of validation issues, instance of the ProtectedHandlerError will be thrown. * * Can pass entire next request or Headers with saleorApiUrl and token + * + * @deprecated Use ProtectedActionValidator class */ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ req, diff --git a/src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts similarity index 90% rename from src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts rename to src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts index ef16a5fa..0237a0ec 100644 --- a/src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts @@ -3,32 +3,30 @@ import { createMocks } from "node-mocks-http"; import rawBody from "raw-body"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MockAPL } from "../../../test-utils/mock-apl"; +import { MockAPL } from "@/test-utils/mock-apl"; +import * as verifySignatureModule from "@/verify-signature"; + import { processSaleorWebhook } from "./process-saleor-webhook"; -vi.mock("../../../verify-signature", () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - verifySignature: vi.fn((domain, signature) => { - if (signature !== "mocked_signature") { - throw new Error("Wrong signature"); - } - }), - verifySignatureFromApiUrl: vi.fn((domain, signature) => { +vi.spyOn(verifySignatureModule, "verifySignatureFromApiUrl").mockImplementation( + async (domain, signature) => { if (signature !== "mocked_signature") { throw new Error("Wrong signature"); } - }), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - verifySignatureWithJwks: vi.fn((jwks, signature, body) => { + } +); +vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockImplementation( + async (domain, signature) => { if (signature !== "mocked_signature") { throw new Error("Wrong signature"); } - }), -})); + } +); vi.mock("raw-body", () => ({ default: vi.fn().mockResolvedValue("{}"), })); + describe("processAsyncSaleorWebhook", () => { let mockRequest: NextApiRequest; diff --git a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts similarity index 78% rename from src/handlers/next/saleor-webhooks/process-saleor-webhook.ts rename to src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts index a6451802..f62e4b70 100644 --- a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts @@ -2,54 +2,17 @@ import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; 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 { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { fetchRemoteJwks } from "@/fetch-remote-jwks"; +import { WebhookContext, WebhookError } from "@/handlers/shared/process-saleor-webhook"; +import { getBaseUrl, getSaleorHeaders } from "@/headers"; +import { getOtelTracer } from "@/open-telemetry"; +import { parseSchemaVersion } from "@/util"; +import { verifySignatureWithJwks } from "@/verify-signature"; 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; @@ -57,13 +20,15 @@ interface ProcessSaleorWebhookArgs { } type ProcessSaleorWebhook = ( - props: ProcessSaleorWebhookArgs + props: ProcessSaleorWebhookArgs, ) => Promise>; /** * Perform security checks on given request and return WebhookContext object. * In case of validation issues, instance of the WebhookError will be thrown. * + * @deprecated + * * @returns WebhookContext */ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ @@ -115,7 +80,7 @@ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ throw new WebhookError( `Wrong incoming request event: ${event}. Expected: ${expected}`, - "WRONG_EVENT" + "WRONG_EVENT", ); } @@ -164,7 +129,7 @@ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ throw new WebhookError( `Can't find auth data for ${saleorApiUrl}. Please register the application`, - "NOT_REGISTERED" + "NOT_REGISTERED", ); } @@ -205,7 +170,7 @@ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ throw new WebhookError( "Request signature check failed", - "SIGNATURE_VERIFICATION_FAILED" + "SIGNATURE_VERIFICATION_FAILED", ); } } @@ -233,6 +198,6 @@ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ } finally { span.end(); } - } + }, ); }; diff --git a/src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts similarity index 80% rename from src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts rename to src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts index 7c4dbeeb..7b7136f6 100644 --- a/src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts @@ -2,16 +2,18 @@ import { ASTNode } from "graphql"; import { createMocks } from "node-mocks-http"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { MockAPL } from "../../../test-utils/mock-apl"; -import { AsyncWebhookEventType } from "../../../types"; -import { processSaleorWebhook } from "./process-saleor-webhook"; +import { WebhookError } from "@/handlers/shared/process-saleor-webhook"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; +import { AsyncWebhookEventType } from "@/types"; + import { SaleorAsyncWebhook } from "./saleor-async-webhook"; import { NextWebhookApiHandler, WebhookConfig } from "./saleor-webhook"; const webhookPath = "api/webhooks/product-updated"; const baseUrl = "http://example.com"; -describe("SaleorAsyncWebhook", () => { +describe("Next.js SaleorAsyncWebhook", () => { const mockAPL = new MockAPL(); afterEach(async () => { @@ -56,22 +58,22 @@ describe("SaleorAsyncWebhook", () => { }); it("Test createHandler which return success", async () => { - // prepare mocked context returned by mocked process function - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ - baseUrl: "example.com", - event: "product_updated", - payload: { data: "test_payload" }, - schemaVersion: 3.19, - authData: { - domain: "example.com", - token: "token", - jwks: "", - saleorApiUrl: "https://example.com/graphql/", - appId: "12345", + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "product_updated", + payload: { data: "test_payload" }, + schemaVersion: 3.19, + authData: { + domain: "example.com", + token: "token", + jwks: "", + saleorApiUrl: "https://example.com/graphql/", + appId: "12345", + }, }, - })); + }); // Test handler - will throw error if mocked context is not passed to it // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -108,16 +110,9 @@ describe("SaleorAsyncWebhook", () => { }); // prepare mocked context returned by mocked process function - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => { - /** - * This mock should throw WebhookError, but there was TypeError related to constructor of extended class. - * Try "throw new WebhookError()" to check it. - * - * For test suite it doesn't matter, because errors thrown from source code are valid - */ - throw new Error("Test error message"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new WebhookError("Test error message", "OTHER"), }); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -143,8 +138,7 @@ describe("SaleorAsyncWebhook", () => { expect.objectContaining({ message: "Test error message", }), - req, - res + req ); /** diff --git a/src/handlers/next/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts similarity index 96% rename from src/handlers/next/saleor-webhooks/saleor-async-webhook.ts rename to src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts index 8efa7a26..327a87ce 100644 --- a/src/handlers/next/saleor-webhooks/saleor-async-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts @@ -1,7 +1,8 @@ import { ASTNode } from "graphql/index"; import { NextApiHandler } from "next"; -import { AsyncWebhookEventType } from "../../../types"; +import { AsyncWebhookEventType } from "@/types"; + import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; export class SaleorAsyncWebhook extends SaleorWebhook { diff --git a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts similarity index 69% rename from src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts rename to src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts index 22431a83..7e011683 100644 --- a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts @@ -1,29 +1,31 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, it, vi } from "vitest"; -import { MockAPL } from "../../../test-utils/mock-apl"; -import { processSaleorWebhook } from "./process-saleor-webhook"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; + import { SaleorSyncWebhook } from "./saleor-sync-webhook"; -describe("SaleorSyncWebhook", () => { +describe("Next.js SaleorSyncWebhook", () => { const mockApl = new MockAPL(); it("Provides type-safe response builder in the context", async () => { - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ - baseUrl: "example.com", - event: "CHECKOUT_CALCULATE_TAXES", - payload: { data: "test_payload" }, - schemaVersion: 3.19, - authData: { - domain: mockApl.workingSaleorDomain, - token: mockApl.mockToken, - jwks: mockApl.mockJwks, - saleorApiUrl: mockApl.workingSaleorApiUrl, - appId: mockApl.mockAppId, + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "CHECKOUT_CALCULATE_TAXES", + payload: { data: "test_payload" }, + schemaVersion: 3.19, + authData: { + domain: mockApl.workingSaleorDomain, + token: mockApl.mockToken, + jwks: mockApl.mockJwks, + saleorApiUrl: mockApl.workingSaleorApiUrl, + appId: mockApl.mockAppId, + }, }, - })); + }); const { req, res } = createMocks({ method: "POST", diff --git a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts similarity index 86% rename from src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts rename to src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts index 3ab6b63f..9aaa216b 100644 --- a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts @@ -1,8 +1,9 @@ import { NextApiHandler } from "next"; -import { SyncWebhookEventType } from "../../../types"; +import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@/types"; + import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; -import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; type InjectedContext = { buildResponse: typeof buildSyncWebhookResponsePayload; diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 00000000..17fe36f6 --- /dev/null +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,48 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +import { createDebug } from "@/debug"; +import { + GenericSaleorWebhook, + GenericWebhookConfig, +} from "@/handlers/shared/generic-saleor-webhook"; +import { WebhookContext } from "@/handlers/shared/process-saleor-webhook"; +import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; + +import { NextJsAdapter } from "../platform-adapter"; + +const debug = createDebug("SaleorWebhook"); + +export type WebhookConfig = + GenericWebhookConfig; + +export type NextWebhookApiHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: WebhookContext & TExtras +) => unknown | Promise; + +export abstract class SaleorWebhook< + TPayload = unknown, + TExtras extends Record = {} +> extends GenericSaleorWebhook { + /** + * 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: NextWebhookApiHandler): NextApiHandler { + return async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const prepareRequestResult = await super.prepareRequest(adapter); + + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } + + debug("Incoming request validated. Call handlerFn"); + return handlerFn(req, res, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); + }; + } +} diff --git a/src/handlers/shared/adapter-middleware.ts b/src/handlers/shared/adapter-middleware.ts new file mode 100644 index 00000000..ffea6864 --- /dev/null +++ b/src/handlers/shared/adapter-middleware.ts @@ -0,0 +1,69 @@ +import { + SALEOR_API_URL_HEADER, + SALEOR_AUTHORIZATION_BEARER_HEADER, + SALEOR_DOMAIN_HEADER, + SALEOR_EVENT_HEADER, + SALEOR_SCHEMA_VERSION, + SALEOR_SIGNATURE_HEADER, +} from "@/const"; +import { createMiddlewareDebug } from "@/middleware/middleware-debug"; + +import { + ActionHandlerResult, + HTTPMethod, + PlatformAdapterInterface, +} from "./generic-adapter-use-case-types"; + +const debug = createMiddlewareDebug("PlatformAdapterMiddleware"); + +export class PlatformAdapterMiddleware { + constructor(private adapter: PlatformAdapterInterface) {} + + withMethod(methods: HTTPMethod[]): ActionHandlerResult | null { + if (!methods.includes(this.adapter.method)) { + return { + body: "Method not allowed", + bodyType: "string", + status: 405, + }; + } + + return null; + } + + withSaleorDomainPresent(): ActionHandlerResult | null { + const { domain } = this.getSaleorHeaders(); + debug("withSaleorDomainPresent middleware called with domain in header: %s", domain); + + if (!domain) { + debug("Domain not found in header, will respond with Bad Request"); + + return { + body: "Missing Saleor domain header.", + bodyType: "string", + status: 400, + }; + } + + return null; + } + + private toStringOrUndefined = (value: string | string[] | undefined | null) => + value ? value.toString() : undefined; + + private toFloatOrNull = (value: string | string[] | undefined | null) => + value ? parseFloat(value.toString()) : null; + + getSaleorHeaders() { + return { + domain: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_DOMAIN_HEADER)), + authorizationBearer: this.toStringOrUndefined( + this.adapter.getHeader(SALEOR_AUTHORIZATION_BEARER_HEADER) + ), + signature: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SIGNATURE_HEADER)), + event: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_EVENT_HEADER)), + saleorApiUrl: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_API_URL_HEADER)), + schemaVersion: this.toFloatOrNull(this.adapter.getHeader(SALEOR_SCHEMA_VERSION)), + }; + } +} 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/generic-adapter-use-case-types.ts b/src/handlers/shared/generic-adapter-use-case-types.ts new file mode 100644 index 00000000..013fe526 --- /dev/null +++ b/src/handlers/shared/generic-adapter-use-case-types.ts @@ -0,0 +1,48 @@ +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]; + +/** Status code of the result, for most platforms it's mapped to HTTP status code + * however when request is not HTTP it can be mapped to something else */ +export type ResultStatusCodes = number; + +/** Shape of result that should be returned from use case + * that is then translated by adapter to a valid platform response */ +export type ActionHandlerResult = + | { + status: ResultStatusCodes; + body: Body; + bodyType: "json"; + } + | { + status: ResultStatusCodes; + body: string; + bodyType: "string"; + }; + +/** + * Interface for adapters that translate specific platform objects (e.g. Web API, Next.js) + * into a common interface that can be used in each handler use case + * */ +export interface PlatformAdapterInterface { + send(result: ActionHandlerResult): unknown; + getHeader(name: string): string | null; + getBody(): Promise; + getRawBody(): Promise; + getBaseUrl(): string; + method: HTTPMethod; + request: T; +} + +/** Interfaces for use case handlers that encapsulate business logic + * (e.g. validating headers, checking HTTP method, etc. ) */ +export interface ActionHandlerInterface { + handleAction(...params: [unknown]): Promise; +} diff --git a/src/handlers/shared/generic-saleor-webhook.ts b/src/handlers/shared/generic-saleor-webhook.ts new file mode 100644 index 00000000..b3db8f73 --- /dev/null +++ b/src/handlers/shared/generic-saleor-webhook.ts @@ -0,0 +1,212 @@ +import { ASTNode } from "graphql"; + +import { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { gqlAstToString } from "@/gql-ast-to-string"; +import { PlatformAdapterInterface, PlatformAdapterMiddleware } from "@/handlers/shared"; +import { WebhookContext, WebhookError } from "@/handlers/shared/process-saleor-webhook"; +import { WebhookErrorCodeMap } from "@/handlers/shared/saleor-webhook"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "@/types"; + +const debug = createDebug("SaleorWebhook"); + +export interface GenericWebhookConfig< + RequestType, + Event = AsyncWebhookEventType | SyncWebhookEventType +> { + name?: string; + webhookPath: string; + event: Event; + isActive?: boolean; + apl: APL; + onError?(error: WebhookError | Error, request: RequestType): void; + formatErrorResponse?( + error: WebhookError | Error, + request: RequestType + ): Promise<{ + code: number; + body: string; + }>; + query: string | ASTNode; + /** + * @deprecated will be removed in 0.35.0, use query field instead + */ + subscriptionQueryAst?: ASTNode; +} + +export abstract class GenericSaleorWebhook< + TRequestType, + TPayload = unknown, + TExtras extends Record = {} +> { + private webhookValidator = new SaleorWebhookValidator(); + + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + + name: string; + + webhookPath: string; + + query: string | ASTNode; + + event: AsyncWebhookEventType | SyncWebhookEventType; + + isActive?: boolean; + + apl: APL; + + onError: GenericWebhookConfig["onError"]; + + formatErrorResponse: GenericWebhookConfig["formatErrorResponse"]; + + protected constructor(configuration: GenericWebhookConfig) { + 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"); + } + } + } + + protected async prepareRequest>( + adapter: Adapter + ): Promise< + | { result: "callHandler"; context: WebhookContext } + | { result: "sendResponse"; response: ReturnType } + > { + const adapterMiddleware = new PlatformAdapterMiddleware(adapter); + const validationResult = await this.webhookValidator.validateRequest({ + allowedEvent: this.event, + apl: this.apl, + adapter, + adapterMiddleware, + }); + + if (validationResult.result === "ok") { + return { result: "callHandler", context: validationResult.context }; + } + + const { error } = validationResult; + + debug(`Unexpected error during processing the webhook ${this.name}`); + + if (error instanceof WebhookError) { + debug(`Validation error: ${error.message}`); + + if (this.onError) { + this.onError(error, adapter.request); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(error, adapter.request); + + return { + result: "sendResponse", + response: adapter.send({ + status: code, + body, + bodyType: "string", + }) as ReturnType, + }; + } + + return { + result: "sendResponse", + response: adapter.send({ + bodyType: "json", + body: { + error: { + type: error.errorType, + message: error.message, + }, + }, + status: WebhookErrorCodeMap[error.errorType] || 400, + }) as ReturnType, + }; + } + debug("Unexpected error: %O", error); + + if (this.onError) { + this.onError(error, adapter.request); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(error, adapter.request); + + return { + result: "sendResponse", + response: adapter.send({ + status: code, + body, + bodyType: "string", + }) as ReturnType, + }; + } + + return { + result: "sendResponse", + response: adapter.send({ + status: 500, + body: "Unexpected error while handling request", + bodyType: "string", + }) as ReturnType, + }; + } + + abstract createHandler(handlerFn: unknown): unknown; +} diff --git a/src/handlers/shared/index.ts b/src/handlers/shared/index.ts new file mode 100644 index 00000000..2e928dfd --- /dev/null +++ b/src/handlers/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./adapter-middleware"; +export * from "./create-app-register-handler-types"; +export * from "./generic-adapter-use-case-types"; +export * from "./protected-action-validator"; diff --git a/src/handlers/shared/process-saleor-webhook.ts b/src/handlers/shared/process-saleor-webhook.ts new file mode 100644 index 00000000..326dbb49 --- /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: TPayload; + authData: AuthData; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; +}; diff --git a/src/handlers/shared/protected-action-validator.ts b/src/handlers/shared/protected-action-validator.ts new file mode 100644 index 00000000..994f234f --- /dev/null +++ b/src/handlers/shared/protected-action-validator.ts @@ -0,0 +1,198 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { APL, AuthData } from "@/APL"; +import { createDebug } from "@/debug"; +import { getOtelTracer } from "@/open-telemetry"; +import { Permission } from "@/types"; +import { extractUserFromJwt, TokenUserPayload } from "@/util/extract-user-from-jwt"; +import { verifyJWT } from "@/verify-jwt"; + +import { PlatformAdapterMiddleware } from "./adapter-middleware"; +import { ActionHandlerResult, PlatformAdapterInterface } from "./generic-adapter-use-case-types"; + +export type ProtectedHandlerConfig = { + apl: APL; + requiredPermissions?: Permission[]; +}; + +export type ProtectedHandlerContext = { + baseUrl: string; + authData: AuthData; + user: TokenUserPayload; +}; + +export type ValidationResult = + | { result: "failure"; value: ActionHandlerResult } + | { result: "ok"; value: ProtectedHandlerContext }; + +export class ProtectedActionValidator { + // Name left for backwards compatibility + private debug = createDebug("processProtectedHandler"); + + private tracer = getOtelTracer(); + + constructor(private adapter: PlatformAdapterInterface) {} + + private adapterMiddleware = new PlatformAdapterMiddleware(this.adapter); + + /** Validates received request if it's legitimate webhook request from Saleor + * returns ActionHandlerResult if request is invalid and must be terminated early + * */ + async validateRequest(config: ProtectedHandlerConfig): Promise { + return this.tracer.startActiveSpan( + "processSaleorProtectedHandler", + { + kind: SpanKind.INTERNAL, + attributes: { + requiredPermissions: config.requiredPermissions, + }, + }, + async (span): Promise => { + this.debug("Request processing started"); + + const { saleorApiUrl, authorizationBearer: token } = + this.adapterMiddleware.getSaleorHeaders(); + + const baseUrl = this.adapter.getBaseUrl(); + + span.setAttribute("saleorApiUrl", saleorApiUrl ?? ""); + + if (!baseUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing host header", + }) + .end(); + + this.debug("Missing host header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing host header", + }, + }; + } + + if (!saleorApiUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing saleor-api-url header", + }) + .end(); + + this.debug("Missing saleor-api-url header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing saleor-api-url header", + }, + }; + } + + if (!token) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing authorization-bearer header", + }) + .end(); + + this.debug("Missing authorization-bearer header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing authorization-bearer header", + }, + }; + } + + // Check if API URL has been registered in the APL + const authData = await config.apl.get(saleorApiUrl); + + if (!authData) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "APL didn't found auth data for API URL", + }) + .end(); + + this.debug("APL didn't found auth data for API URL %s", saleorApiUrl); + + return { + result: "failure", + value: { + bodyType: "string", + status: 401, + body: `Validation error: Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`, + }, + }; + } + + try { + await verifyJWT({ + appId: authData.appId, + token, + saleorApiUrl, + requiredPermissions: config.requiredPermissions, + }); + } catch (e) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "JWT verification failed", + }) + .end(); + + return { + result: "failure", + value: { + bodyType: "string", + status: 401, + body: "Validation error: JWT verification failed", + }, + }; + } + + try { + const userJwtPayload = extractUserFromJwt(token); + + span.end(); + return { + result: "ok", + value: { + baseUrl, + authData, + user: userJwtPayload, + }, + }; + } catch (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "Error parsing user from JWT", + }); + span.end(); + return { + result: "failure", + value: { + bodyType: "string", + status: 500, + body: "Unexpected error: parsing user from JWT", + }, + }; + } + } + ); + } +} diff --git a/src/handlers/shared/protected-handler.ts b/src/handlers/shared/protected-handler.ts new file mode 100644 index 00000000..fc9ef166 --- /dev/null +++ b/src/handlers/shared/protected-handler.ts @@ -0,0 +1,20 @@ +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 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-validator.test.ts b/src/handlers/shared/saleor-webhook-validator.test.ts new file mode 100644 index 00000000..22044585 --- /dev/null +++ b/src/handlers/shared/saleor-webhook-validator.test.ts @@ -0,0 +1,260 @@ +/* eslint-disable max-classes-per-file */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { MockAPL } from "@/test-utils/mock-apl"; +import * as verifySignatureModule from "@/verify-signature"; + +import { PlatformAdapterMiddleware } from "./adapter-middleware"; +import { HTTPMethod, PlatformAdapterInterface } from "./generic-adapter-use-case-types"; +import { SaleorWebhookValidator } from "./saleor-webhook-validator"; + +vi.spyOn(verifySignatureModule, "verifySignatureFromApiUrl").mockImplementation( + async (domain, signature) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + } +); +vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockImplementation( + async (domain, signature) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + } +); + +class MockAdapter implements PlatformAdapterInterface { + send() { + throw new Error("Method not implemented."); + } + + getHeader() { + return null; + } + + async getBody() { + return null; + } + + async getRawBody() { + return "{}"; + } + + getBaseUrl() { + return ""; + } + + method: HTTPMethod = "POST"; + + request = {}; +} + +describe("SaleorWebhookValidator", () => { + const mockAPL = new MockAPL(); + const validator = new SaleorWebhookValidator(); + let adapter: MockAdapter; + let middleware: PlatformAdapterMiddleware; + + const validHeaders = { + saleorApiUrl: mockAPL.workingSaleorApiUrl, + event: "product_updated", + schemaVersion: 3.2, + signature: "mocked_signature", + authorizationBearer: "mocked_bearer", + domain: "example.com", + }; + + beforeEach(() => { + adapter = new MockAdapter(); + middleware = new PlatformAdapterMiddleware(adapter); + vi.spyOn(adapter, "getBaseUrl").mockReturnValue("https://example-app.com/api"); + }); + + it("Processes valid request", async () => { + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + baseUrl: "https://example-app.com/api", + event: "product_updated", + payload: {}, + schemaVersion: null, + }), + }); + }); + + it("Throws error on non-POST request method", async () => { + vi.spyOn(adapter, "method", "get").mockReturnValue("GET"); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Wrong request method, only POST allowed", + }), + }); + }); + + it("Throws error on missing api url header", async () => { + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + // @ts-expect-error testing missing saleorApiUrl + saleorApiUrl: null, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Missing saleor-api-url header", + }), + }); + }); + + it("Throws error on missing event header", async () => { + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue({ + // @ts-expect-error testing missing event + event: null, + signature: "mocked_signature", + saleorApiUrl: mockAPL.workingSaleorApiUrl, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Missing saleor-event header", + }), + }); + }); + + it("Throws error on mismatched event header", async () => { + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + event: "different_event", + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Wrong incoming request event: different_event. Expected: product_updated", + }), + }); + }); + + it("Throws error on missing signature header", async () => { + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + // @ts-expect-error testing missing signature + signature: null, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Missing saleor-signature header", + }), + }); + }); + + it("Throws error on missing request body", async () => { + vi.spyOn(adapter, "getRawBody").mockResolvedValue(""); + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: "Missing request body", + }), + }); + }); + + it("Throws error on unregistered app", async () => { + const unregisteredApiUrl = "https://not-registered.example.com/graphql/"; + + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + saleorApiUrl: unregisteredApiUrl, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "failure", + error: expect.objectContaining({ + message: `Can't find auth data for ${unregisteredApiUrl}. Please register the application`, + }), + }); + }); + + it("Fallbacks to null if version is missing in payload", async () => { + vi.spyOn(adapter, "getRawBody").mockResolvedValue(JSON.stringify({})); + vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + adapterMiddleware: middleware, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + schemaVersion: null, + }), + }); + }); +}); diff --git a/src/handlers/shared/saleor-webhook-validator.ts b/src/handlers/shared/saleor-webhook-validator.ts new file mode 100644 index 00000000..a2ef6119 --- /dev/null +++ b/src/handlers/shared/saleor-webhook-validator.ts @@ -0,0 +1,219 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { fetchRemoteJwks } from "@/fetch-remote-jwks"; +import { getOtelTracer } from "@/open-telemetry"; +import { parseSchemaVersion } from "@/util"; +import { verifySignatureWithJwks } from "@/verify-signature"; + +import { PlatformAdapterMiddleware } from "./adapter-middleware"; +import { PlatformAdapterInterface } from "./generic-adapter-use-case-types"; +import { WebhookContext, WebhookError } from "./process-saleor-webhook"; + +type WebhookValidationResult = + | { result: "ok"; context: WebhookContext } + | { result: "failure"; error: WebhookError }; + +export class SaleorWebhookValidator { + private debug = createDebug("processProtectedHandler"); + + private tracer = getOtelTracer(); + + async validateRequest(config: { + allowedEvent: string; + apl: APL; + adapter: PlatformAdapterInterface; + adapterMiddleware: PlatformAdapterMiddleware; + }): Promise> { + try { + const context = await this.validateRequestOrThrowError(config); + + return { + result: "ok", + context, + }; + } catch (err) { + return { + result: "failure", + error: err as WebhookError, + }; + } + } + + private async validateRequestOrThrowError({ + allowedEvent, + apl, + adapter, + adapterMiddleware, + }: { + allowedEvent: string; + apl: APL; + adapter: PlatformAdapterInterface; + adapterMiddleware: PlatformAdapterMiddleware; + }): Promise> { + return this.tracer.startActiveSpan( + "processSaleorWebhook", + { + kind: SpanKind.INTERNAL, + attributes: { + allowedEvent, + }, + }, + async (span) => { + try { + this.debug("Request processing started"); + + if (adapter.method !== "POST") { + this.debug("Wrong HTTP method"); + throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); + } + + const { event, signature, saleorApiUrl } = adapterMiddleware.getSaleorHeaders(); + const baseUrl = adapter.getBaseUrl(); + + if (!baseUrl) { + this.debug("Missing host header"); + throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + this.debug("Missing saleor-api-url header"); + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!event) { + this.debug("Missing saleor-event header"); + throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); + } + + const expected = allowedEvent.toLowerCase(); + + if (event !== expected) { + this.debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); + + throw new WebhookError( + `Wrong incoming request event: ${event}. Expected: ${expected}`, + "WRONG_EVENT" + ); + } + + if (!signature) { + this.debug("No signature"); + + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); + } + + const rawBody = await adapter.getRawBody(); + if (!rawBody) { + this.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 { + this.debug("Request body cannot be parsed"); + + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); + } + + let parsedSchemaVersion: number | null = null; + + try { + parsedSchemaVersion = parseSchemaVersion(parsedBody.version); + } catch { + this.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) { + this.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 { + this.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 { + this.debug("Request signature check failed. Refresh the JWKS cache and check again"); + + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + this.debug(e); + + throw new WebhookError( + "Fetching remote JWKS failed", + "SIGNATURE_VERIFICATION_FAILED" + ); + }); + + this.debug("Fetched refreshed JWKS"); + + try { + this.debug( + "Second attempt to validate the signature JWKS, using fresh tokens from the API" + ); + + await verifySignatureWithJwks(newJwks, signature, rawBody); + + this.debug("Verification successful - update JWKS in the AuthData"); + + await apl.set({ ...authData, jwks: newJwks }); + } catch { + this.debug("Second attempt also ended with validation error. Reject the webhook"); + + throw new WebhookError( + "Request signature check failed", + "SIGNATURE_VERIFICATION_FAILED" + ); + } + } + + span.setStatus({ + code: SpanStatusCode.OK, + }); + + return { + baseUrl, + event, + payload: parsedBody as TPayload, + authData, + schemaVersion: parsedSchemaVersion, + }; + } catch (err) { + const message = (err as Error)?.message ?? "Unknown error"; + + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + throw err; + } finally { + span.end(); + } + } + ); + } +} diff --git a/src/handlers/shared/saleor-webhook.ts b/src/handlers/shared/saleor-webhook.ts new file mode 100644 index 00000000..10650ab1 --- /dev/null +++ b/src/handlers/shared/saleor-webhook.ts @@ -0,0 +1,19 @@ +import { ResultStatusCodes } from "./generic-adapter-use-case-types"; +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 82% rename from src/handlers/next/validate-allow-saleor-urls.ts rename to src/handlers/shared/validate-allow-saleor-urls.ts index 9b7c39c5..58a91fc4 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 "../platforms/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/tsconfig.json b/tsconfig.json index 504417b7..c9ad42cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,106 +1,19 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "lib": [ - "dom", - "ES2021" - ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, - "jsx": "react" /* Specify what JSX code is generated. */, - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - "resolveJsonModule": true /* Enable importing .json files. */, - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES2021", + "lib": ["dom", "ES2021"], + "jsx": "react", + "useDefineForClassFields": false, + "module": "commonjs", + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } } } diff --git a/tsup.config.ts b/tsup.config.ts index f94bb74d..c24eea50 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,23 +1,33 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: [ - "src/const.ts", - "src/types.ts", - "src/urls.ts", - "src/headers.ts", - "src/saleor-app.ts", - "src/verify-jwt.ts", - "src/verify-signature.ts", - "src/APL/index.ts", - "src/APL/redis/index.ts", - "src/APL/vercel-kv/index.ts", - "src/app-bridge/index.ts", - "src/app-bridge/next/index.ts", - "src/handlers/next/index.ts", - "src/middleware/index.ts", - "src/settings-manager/index.ts", - ], + entry: { + const: "src/const.ts", + types: "src/types.ts", + urls: "src/urls.ts", + headers: "src/headers.ts", + "saleor-app": "src/saleor-app.ts", + "verify-jwt": "src/verify-jwt.ts", + "verify-signature": "src/verify-signature.ts", + "APL/index": "src/APL/index.ts", + "APL/redis/index": "src/APL/redis/index.ts", + "APL/vercel-kv/index": "src/APL/vercel-kv/index.ts", + "app-bridge/index": "src/app-bridge/index.ts", + "app-bridge/next/index": "src/app-bridge/next/index.ts", + "fetch-middleware/index": "src/fetch-middleware/index.ts", + "middleware/index": "src/middleware/index.ts", + "settings-manager/index": "src/settings-manager/index.ts", + + // Mapped exports + "handlers/next/index": "src/handlers/platforms/next/index.ts", + "handlers/fetch-api/index": "src/handlers/platforms/fetch-api/index.ts", + "handlers/aws-lambda/index": "src/handlers/platforms/fetch-api/index.ts", + "handlers/shared/index": "src/handlers/shared/index.ts", + "handlers/actions/index": "src/handlers/actions/index.ts", + + // Virtual export + "handlers/next-app-router/index": "src/handlers/platforms/fetch-api/index.ts", + }, dts: true, clean: true, format: ["esm", "cjs"], diff --git a/vitest.config.ts b/vitest.config.mts similarity index 66% rename from vitest.config.ts rename to vitest.config.mts index 333445f5..42dc8aaf 100644 --- a/vitest.config.ts +++ b/vitest.config.mts @@ -1,10 +1,14 @@ import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths" import { defineConfig } from "vitest/config"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tsconfigPaths()], test: { + alias: { + // "@": "/src", + }, setupFiles: ["./src/setup-tests.ts"], environment: "jsdom", css: false,