This commit is contained in:
jackbeeby
2024-12-05 18:55:18 +11:00
commit 09db3a54c3
291 changed files with 102282 additions and 0 deletions

15
.changeset/config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"version": true,
"tag": true
}
}

274
.dependency-cruiser.js Normal file
View File

@@ -0,0 +1,274 @@
/** @type {import('dependency-cruiser').IConfiguration} */
export default {
forbidden: [
/* rules from the 'recommended' preset: */
{
name: "no-circular",
severity: "warn",
comment:
"This dependency is part of a circular relationship. You might want to revise " +
"your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
from: {},
to: {
circular: true,
},
},
{
name: "no-orphans",
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: "warn",
from: {
orphan: true,
pathNot: [
"(^|/)\\.[^/]+\\.(js|jsx|cjs|mjs|ts|tsx|cts|ctsx|mts|mtsx|json)$", // dot files
"\\.d\\.ts$", // TypeScript declaration files
"(^|/)tsconfig\\.json$", // TypeScript config
"(^|/)(babel|webpack)\\.config\\.(js|jsx|cjs|mjs|ts|tsx|cts|ctsx|mts|mtsx|json)$", // other configs
],
},
to: {},
},
{
name: "no-deprecated-core",
comment:
"A module depends on a node core module that has been deprecated. Find an alternative - these are " +
"bound to exist - node doesn't deprecate lightly.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["core"],
path: [
"^(v8/tools/codemap)$",
"^(v8/tools/consarray)$",
"^(v8/tools/csvparser)$",
"^(v8/tools/logreader)$",
"^(v8/tools/profile_view)$",
"^(v8/tools/profile)$",
"^(v8/tools/SourceMap)$",
"^(v8/tools/splaytree)$",
"^(v8/tools/tickprocessor-driver)$",
"^(v8/tools/tickprocessor)$",
"^(node-inspect/lib/_inspect)$",
"^(node-inspect/lib/internal/inspect_client)$",
"^(node-inspect/lib/internal/inspect_repl)$",
"^(async_hooks)$",
"^(punycode)$",
"^(domain)$",
"^(constants)$",
"^(sys)$",
"^(_linklist)$",
"^(_stream_wrap)$",
],
},
},
{
name: "not-to-deprecated",
comment:
"This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
"version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["deprecated"],
},
},
{
name: "no-non-package-json",
severity: "error",
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
"available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
"in your package.json.",
from: {},
to: {
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
},
},
{
name: "not-to-unresolvable",
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
"module: add it to your package.json. In all other cases you likely already know what to do.",
severity: "error",
from: {
pathNot: "generated/graphql.ts",
},
to: {
pathNot: "@saleor/app-sdk/types",
couldNotResolve: true,
dependencyTypesNot: ["type-only"],
},
},
{
name: "no-duplicate-dep-types",
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: "warn",
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ["type-only"],
},
},
/* rules you might want to tweak for your specific situation: */
{
name: "not-to-spec",
comment:
"This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: "error",
from: {
pathNot:
"(saleor-app\\.ts$)|(__tests__/\\.*)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md)$)",
},
to: {
path: "(__tests__/\\.*)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md)$)",
},
},
{
name: "not-to-dev-dep",
severity: "error",
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
"package.json. It looks like something that ships to production, though. To prevent problems " +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
"section of your package.json. If this module is development only - add it to the " +
"from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
from: {
path: "^(src)",
pathNot:
"(src/schemas/compiler.mts)|(.*/__tests__/.*)|(setup-tests\\.ts$)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md))$",
},
to: {
dependencyTypes: ["npm-dev"],
},
},
{
name: "optional-deps-used",
severity: "info",
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: ["npm-optional"],
},
},
{
name: "peer-deps-used",
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["npm-peer"],
},
},
],
options: {
/* conditions specifying which files not to follow further when encountered:
- path: a regular expression to match
- dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/master/doc/rules-reference.md#dependencytypes-and-dependencytypesnot
for a complete list
*/
doNotFollow: {
path: "node_modules",
},
/* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation
true: also detect dependencies that only exist before typescript-to-javascript compilation
"specify": for each dependency identify whether it only exists before compilation or also after
*/
tsPreCompilationDeps: true,
/* TypeScript project file ('tsconfig.json') to use for
(1) compilation and
(2) resolution (e.g. with the paths property)
The (optional) fileName attribute specifies which file to take (relative to
dependency-cruiser's current working directory). When not provided
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: "tsconfig.json",
},
/* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. You can set most of these
options in a webpack.conf.js - this section is here for those
projects that don't have a separate webpack config file.
Note: settings in webpack.conf.js override the ones specified here.
*/
enhancedResolveOptions: {
/* List of strings to consider as 'exports' fields in package.json. Use
['exports'] when you use packages that use such a field and your environment
supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack).
If you have an `exportsFields` attribute in your webpack config, that one
will have precedence over the one specified here.
*/
exportsFields: ["exports"],
/* List of conditions to check for in the exports field. e.g. use ['imports']
if you're only interested in exposed es6 modules, ['require'] for commonjs,
or all conditions at once `(['import', 'require', 'node', 'default']`)
if anything goes for you. Only works when the 'exportsFields' array is
non-empty.
If you have a 'conditionNames' attribute in your webpack config, that one will
have precedence over the one specified here.
*/
conditionNames: ["import", "require", "node", "default"],
/*
If your TypeScript project makes use of types specified in 'types'
fields in package.jsons of external dependencies, specify "types"
in addition to "main" in here, so enhanced-resolve (the resolver
dependency-cruiser uses) knows to also look there. You can also do
this if you're not sure, but still use TypeScript. In a future version
of dependency-cruiser this will likely become the default.
*/
mainFields: ["module", "main", "types"],
},
reporterOptions: {
dot: {
/* pattern of modules that can be consolidated in the detailed
graphical dependency graph. The default pattern in this configuration
collapses everything in node_modules to one folder deep so you see
the external modules, but not the innards your app depends upon.
*/
collapsePattern: "node_modules/(@[^/]+/[^/]+|[^/]+)",
},
archi: {
/* pattern of modules that can be consolidated in the high level
graphical dependency graph. If you use the high level graphical
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern:
"^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)",
},
text: {
highlightFocused: true,
},
},
},
};
// generated: dependency-cruiser@12.11.0 on 2023-03-25T15:14:42.761Z

3
.env Normal file
View File

@@ -0,0 +1,3 @@
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
APP_DEBUG=info
SECRET_KEY=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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
APP_DEBUG=info
SECRET_KEY=aaaaaaaa

14
.env.test Normal file
View File

@@ -0,0 +1,14 @@
APP_DEBUG=error
SECRET_KEY=aaaaaaaa
TEST_SALEOR_API_URL="https://saleor.localhost:8080/graphql/"
TEST_SALEOR_JWKS='{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"1dBwlEgrHxAM64KH-pupx-VBeISR4Jkh6NLIDStGarXQkLECSMrmGd8eIzKZ4vSvOF0zxfE7zFVRTm4MzFWBxrr0YWoWQRDIKteEcUTDerfVQ0NbUPZAz6siIg4X-qI1rWWu85nkjWAFOax6ociMh9nG46pekueATkjd6lxdrkcjLUgRMm_CRoIQL8Ad7tYt67Ua8gvIkXcF5pV6Cr7ukjySnOzavP25k6XkAgmNEI_Nl60rL4a0imoBZUXRoWCXmqpPFNH5HtyHj7FxFEEoqPZ_-4NIT-eTMgrbqC-lLixjQuyZIZcZHXiC0CBcw-cdPSxsqqcxagcSiBrzoX9idw\", \"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}'
# Remaining TEST_* vars must be configured separately
TEST_SALEOR_APP_TOKEN=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_SALEOR_APP_TOKEN
TEST_SALEOR_APP_ID=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_SALEOR_APP_ID
TEST_PAYMENT_APP_SECRET_KEY=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_SECRET_KEY
TEST_PAYMENT_APP_PUBLISHABLE_KEY=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_PUBLISHABLE_KEY
TEST_PAYMENT_APP_WEBHOOK_SECRET=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_WEBHOOK_SECRET
TEST_PAYMENT_APP_WEBHOOK_ID=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_WEBHOOK_ID
# When you add more variables remember to add them in .github/workflows/main.yml and src/__tests__/test-env.mjs

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
generated/
src/schemas/**/*.mjs
src/schemas/**/*.mts

150
.eslintrc Normal file
View File

@@ -0,0 +1,150 @@
{
"$schema": "https://json.schemastore.org/eslintrc.json",
"plugins": ["@typescript-eslint", "require-form-method", "node", "vitest"],
"parserOptions": {
"project": "tsconfig.json"
},
"extends": [
"next",
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier",
"plugin:vitest/recommended",
"plugin:@saleor/saleor-app/recommended"
],
"rules": {
// use double quotes, allow template strings
"quotes": ["error", "double", { "avoidEscape": true }],
// sort imports
"import/order": "error",
// no let exports
"import/no-mutable-exports": "error",
"import/no-cycle": "error",
"import/no-default-export": "error",
// prevent default import from Sentry because it doesn't work 😬
"no-restricted-syntax": [
"error",
{
"selector": "ImportDeclaration[source.value='@sentry/nextjs'] > ImportDefaultSpecifier",
"message": "Use `import * as Sentry from '@sentry/nextjs';`"
}
],
// allow {} even though it's unsafe but comes handy
"@typescript-eslint/ban-types": [
"error",
{
"types": {
"{}": false
}
}
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports",
"disallowTypeAnnotations": false
}
],
"import/no-duplicates": ["error", { "prefer-inline": true }],
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/__tests__/test-env.mjs"],
"message": "Test envs cannot be imported into application code"
}
]
}
],
"node/no-process-env": ["error"],
// false negatives
"import/namespace": ["off"],
// we allow empty interfaces
"no-empty-pattern": "off",
"@typescript-eslint/no-empty-interface": "off",
// we allow empty functions
"@typescript-eslint/no-empty-function": "off",
// we sometimes use async functions that don't await anything
"@typescript-eslint/require-await": "off",
// make sure to `await` inside try…catch
"@typescript-eslint/return-await": ["error", "in-try-catch"],
// allow unused vars prefixed with `_`
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
],
// numbers and booleans are fine in template strings
"@typescript-eslint/restrict-template-expressions": [
"error",
{ "allowNumber": true, "allowBoolean": true }
],
// for security reasons, always require forms to provide method="post"
"require-form-method/require-form-method": "error",
// for security reasons, always require buttons to provide type="button" ("submit" on rare occasions)
"react/button-has-type": ["error", { "button": true, "submit": true, "reset": false }]
},
"overrides": [
{
"files": ["*.test.tsx", "*.test.ts", "**/__tests__/**/*.ts?(x)"],
"rules": {
// let's make our lives easier in tests
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
// allow imports from testEnv
"@typescript-eslint/no-restricted-imports": "off"
}
},
{
// https://github.com/testing-library/eslint-plugin-testing-library#run-the-plugin-only-against-test-files
"files": ["**/__tests__/**/*.[jt]sx", "**/?(*.)+(spec|test).[jt]sx"],
"extends": ["plugin:testing-library/react"]
},
{
// We allow process.env access in env.mjs and testEnv.mjs
"files": ["**/env.mjs", "**/test-env.mjs", "next.config.mjs"],
"rules": {
"node/no-process-env": "off"
}
},
{
"files": ["src/pages/**/*.ts?(x)"],
"rules": {
"import/no-default-export": "off"
}
},
{
"files": ["*.cjs", "*.cts"],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
],
"ignorePatterns": ["*.js", "*.jsx"]
}

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.har linguist-generated=true

48
.graphqlrc.yml Normal file
View File

@@ -0,0 +1,48 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/migrations/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
hooks:
afterAllFileWrite:
- prettier --write
config:
immutableTypes: true
strictScalars: true
defaultScalarType: "unknown"
useTypeImports: true
dedupeFragments: true
skipTypename: true
scalars:
_Any: "unknown"
Date: "string"
DateTime: "string"
Decimal: "number"
Minute: "number"
GenericScalar: "JSONValue"
JSON: "JSONValue"
JSONString: "string"
Metadata: "Record<string, string>"
PositiveDecimal: "number"
Upload: "unknown"
UUID: "string"
WeightScalar: "number"
plugins:
- add:
content: |
/* c8 ignore start */
import type { JSONValue } from '@/types';
- typescript
- typescript-operations:
skipTypeNameForRoot: true
exportFragmentSpreadSubTypes: true
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

13
.lintstagedrc.js Normal file
View File

@@ -0,0 +1,13 @@
// https://nextjs.org/docs/basic-features/eslint#lint-staged
import path from "path";
const buildEslintCommand = (filenames) =>
`next lint --fix --file ${filenames
.map((f) => path.relative(process.cwd(), f))
.join(" --file ")}`;
export default {
"*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}": [buildEslintCommand],
"*.*": "prettier --write --ignore-unknown",
};

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
strict-peer-dependencies=false
save-exact=true

11
.prettierignore Normal file
View File

@@ -0,0 +1,11 @@
.next
pnpm-lock.yaml
graphql/schema.graphql
*.har
src/schemas/**/*.ts
src/schemas/**/*.mts
src/schemas/**/*.cts
src/schemas/**/*.js
src/schemas/**/*.mjs
src/schemas/**/*.cjs
coverage/**

5
.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": false,
"printWidth": 100,
"trailingComma": "all"
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"graphql.vscode-graphql",
"graphql.vscode-graphql-syntax",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/pages/api/test.ts",
"outFiles": ["${workspaceFolder}/**/*.js"]
}
]
}

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.run": "onSave",
"editor.codeActionsOnSave": {
"source.fixAll": "always"
},
"graphql-config.load.rootDir": "./",
"graphql-config.load.configName": "graphql"
}

40
CHANGELOG.md Normal file
View File

@@ -0,0 +1,40 @@
# saleor-app-payment-stripe
## 0.4.0
### Minor Changes
- c69f6b8: Added "ALLOWED_DOMAIN_PATTERN" env that can be used to allow/disallow specific Saleor instances
## 0.3.0
### Minor Changes
- 6817fbe: Fixed blank configuration screen; Stripe API is now on the latest version
### Patch Changes
- e47cd4e: Bumped zod from 3.22.2 to 3.22.3
## 0.2.1
### Patch Changes
- be1419f: Update Sentry to 7.77.0
## 0.2.0
### Minor Changes
- a1df783: Add webhook ID to the summary view
### Patch Changes
- a1df783: Add simple skeletons
- 4d8b58b: Add more logs
## 0.1.0
### Minor Changes
- baf1c0e: Initial release

3
CODEOWNERS Normal file
View File

@@ -0,0 +1,3 @@
* @saleor/apps-guild
/.github/ @saleor/SRE
* @saleor/Shopex-JS

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# syntax = docker/dockerfile:1
ARG NODE_VERSION=22.9.0
FROM node:${NODE_VERSION}-slim AS base
ENV NODE_VERSION="$NODE_VERSION"
LABEL fly_launch_runtime="Next.js"
# Next.js app lives here
WORKDIR /app
# Install pnpm
ARG PNPM_VERSION=8.12.0
RUN npm install -g pnpm@$PNPM_VERSION
FROM base AS build
# Set these via `docker run -e APP_DEBUG=debug ...`
# OR set them in your deployed containers environment secrets
# these are needed for pnpm install schema:generate
ENV APL='file'
ENV APP_DEBUG='debug'
# do not replace these here, set these in the deployed environment, or docker run -e ...
# ENV TEST_SALEOR_API_URL=''
# ENV UPSTASH_TOKEN=''
# ENV UPSTASH_URL=''
# ENV SECRET_KEY='test-see-comment-above'
ENV NODE_ENV="production"
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
COPY . .
RUN SECRET_KEY='test-see-comment-above' pnpm run build
# Remove development dependencies
RUN pnpm prune --prod
# Final stage for app image
FROM base
# Copy built application
COPY --from=build /app /app
# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD [ "pnpm", "run", "start" ]

37
LICENSE Normal file
View File

@@ -0,0 +1,37 @@
BSD 3-Clause License
Copyright (c) 2020-2022, Saleor Commerce
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------
Unless stated otherwise, artwork included in this distribution is licensed
under the Creative Commons Attribution 4.0 International License.
You can learn more about the permitted use by visiting
https://creativecommons.org/licenses/by/4.0/

305
README.md Normal file
View File

@@ -0,0 +1,305 @@
# Stripe App
> [!CAUTION]
> Stripe App is not a production-ready app, but an integration example. It is not feature complete and must be self-hosted.
> [!TIP]
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
## Overview
Stripe App is a payment integration app that allows merchants to accept online payments from customers using Stripe as their payment processor. Stripe is a popular global payment provider that offers a range of payment methods, including credit cards, bank transfers, and digital wallets.
You can find an example of using the Stripe App at [https://github.com/saleor/example-nextjs-stripe/](https://github.com/saleor/example-nextjs-stripe/).
> [!IMPORTANT]
> To configure the Stripe App, you must have an account with [Stripe](https://stripe.com).
The Stripe App allows for integrations with [Stripe Payment Element](https://stripe.com/docs/payments/payment-element), meaning it can be used on [Web, iOS, Android, and React Native](https://stripe.com/docs/payments/accept-a-payment?platform=web). Under the hood, it creates Stripe [Payment Intents](https://stripe.com/docs/api/payment_intents) and handles calculations of total and balance in Saleor automatically.
> [!NOTE]
> Stripe App uses Stripe API version [2022-11-15](https://stripe.com/docs/api/versioning).
## Capabilities
The Stripe App implements the following [Saleor synchronous events related to transactions](https://docs.saleor.io/docs/3.x/developer/extending/webhooks/synchronous-events/transaction):
- [`PAYMENT_GATEWAY_INITIALIZE_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumpayment_gateway_initialize_session)
- [`TRANSACTION_INITIALIZE_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_initialize_session)
- [`TRANSACTION_PROCESS_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_process_session)
- [`TRANSACTION_CHARGE_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_charge_requested)
- [`TRANSACTION_CANCEL_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_cancel_requested)
- [`TRANSACTION_REFUND_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_refund_requested)
Furthermore, it's also prepared to handle [Stripe incoming webhooks](https://stripe.com/docs/webhooks).
Stripe App follows the flow described in detail in the [Saleor Payment App documentation](https://docs.saleor.io/docs/3.x/developer/payments#payment-app).
## Configuration
For Stripe to appear as [available payment gateway](https://docs.saleor.io/docs/3.x/developer/checkout/finalizing#listing-available-payment-gateways), you need to [install it in the Saleor Dashboard](https://docs.saleor.io/docs/3.x/developer/app-store/overview#usage). You must obtain the Secret Key and Publishable Key from Stripe and paste it into the Stripe App configuration form. Webhooks to receive notifications from Stripe will be configured automatically.
> [!CAUTION]
> Stripe App doesn't work with Restricted Keys.
## Usage in Storefront or mobile apps
Stripe App can be used to integrate with Stripe APIs. By using a set of GraphQL mutations, one can interact with Stripe to authorize, capture, refund, and cancel payments.
### Getting payment gateways
The first step is to fetch the Checkout object including [`availablePaymentGateways`](https://docs.saleor.io/docs/3.x/api-reference/checkout/objects/checkout#checkoutavailablepaymentgatewayspaymentgateway--) field. The `availablePaymentGateways` field contains a list of payment gateways available for given checkout. The Stripe App should be one of the payment gateways available in the list. Its `id` is [`app.saleor.stripe`](https://stripe.saleor.app/api/manifest) - defined in app's manifest.
```graphql
query {
checkout(id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy") {
availablePaymentGateways {
id
name
}
}
}
```
The response:
```json
{
"data": {
"checkout": {
"availablePaymentGateways": [
{
"id": "app.saleor.stripe",
"name": "Stripe"
}
]
}
}
}
```
> [!NOTE]
> The `availablePaymentGateways` may contain other Payment Apps as well as [legacy plugins](https://docs.saleor.io/docs/3.x/developer/extending/payment-gateways) configured in the Dashboard. You should ignore the ones that you don't want to use for a specific checkout.
### Paying with Stripe Payment Element
To initialize the Stripe Payment Element, one needs to create a transaction in Saleor by calling the [`transactionInitialize`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/transaction-initialize) mutation.
```graphql
mutation StripeTransactionInitialize($data: JSON!) {
transactionInitialize(
id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy"
amount: 54.24
paymentGateway: { id: "app.saleor.stripe", data: $data }
) {
transactionEvent {
pspReference
amount {
amount
currency
}
type
}
data
errors {
field
message
code
}
}
}
```
Where `$data` is an object passed to Stripe API to create a Payment Intent, for example:
```json
{
"automatic_payment_methods": {
"enabled": true
}
}
```
The mutation returns the `TransactionInitialize` response:
```json
{
"data": {
"transactionInitialize": {
"transactionEvent": {
"pspReference": "XXXX9XXXXXXXXX99",
"amount": {
"amount": 54.24,
"currency": "EUR"
},
"type": "CHARGE_REQUESTED"
},
"data": {
"paymentIntent": {
"client_secret": "…"
},
"publishableKey": "…"
},
"errors": []
}
}
}
```
`client_secret` and `publishableKey` can be used to initialize the Stripe Payment Element.
You can find a working example in this repository: [saleor/example-nextjs-stripe](https://github.com/saleor/example-nextjs-stripe/)
### Modifying the payment intent
You can use the [`transactionProcess`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/transaction-process) mutation to modify the payment intent. For example:
```graphql
mutation StripeTransactionProcess($data: JSON!) {
transactionProcess(
id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy"
data: $data
) {
transactionEvent {
pspReference
amount {
amount
currency
}
type
}
data
errors {
field
message
code
}
}
}
```
Where `$data` is an object passed to Stripe API to edit a Payment Intent, for example:
```json
{
"automatic_payment_methods": {
"enabled": true
}
}
```
### Retrieving publishable key
In some cases, you might want to retrieve just the publishable key without creating any transactions in Saleor. This is particularly useful on a payment summary page where you want to display the details of Stripe Payment Intent. To do so, [`paymentGatewayInitialize`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/payment-gateway-initialize) mutation can be used:
```graphql
mutation PaymentGatewayInitialize($checkoutId: ID!) {
paymentGatewayInitialize(
id: $checkoutId
amount: 0
paymentGateways: [{ id: "app.saleor.stripe" }]
) {
gatewayConfigs {
id
data
errors {
field
message
code
}
}
errors {
field
message
code
}
}
}
```
The response:
```json
{
"data": {
"paymentGatewayInitialize": {
"gatewayConfigs": [
{
"id": "app.saleor.stripe",
"data": {
"publishableKey": "pk_test_…"
},
"errors": []
}
],
"errors": []
}
}
}
```
You can find an example of using the Stripe App at [https://github.com/saleor/example-nextjs-stripe/](https://github.com/saleor/example-nextjs-stripe/).
## Development
To run the Stripe App locally:
1. Go to the app directory.
2. Copy the `.env.example` file to `.env`.The `.env` should contain the following variables:
> [!NOTE]
> Stripe App is a Next.js application. If you want to learn more about setting environment variables in Next.js, head over to the [documentation](https://nextjs.org/docs/basic-features/environment-variables).
`SECRET_KEY` (_required_)
A randomly generated key that encrypts metadata stored in Saleor. At least eight characters long.
`APL` (_optional_)
Name of the chosen implementation of the [Authentication Persistence Layer](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
When no value is provided, `FileAPL` is used by default. See `saleor-app.ts` in the app directory to see supported APLs.
`APP_DEBUG` (_optional_)
The logging level for the app. The possible values are: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, and `silent`. The default value is `info` which means that some information will be logged into the console.
You can read more about our logger in [its documentation](https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter).
### Running app in development mode
To run the app in development mode, run the following command:
```bash
pnpm i
pnpm dev
```
> [!NOTE]
> pnpm 8.0.0 or higher is required to run the app.
The app will be available at `http://localhost:3000`.
> [!NOTE]
> To test Stripe Webhooks, you need to expose your local server to the internet (tunnel). You can use Saleor CLI or Stripe CLI to do that. See [this guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels) for more details.
### Running tests
To run tests, one needs to provide additional environment variables. Copy the `.env.test` file to `.env.test.local`.The `.env.test.local` should contain the following variables:
| env variable name | required? | description | example |
| ---------------------------------- | :--------: | :------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- |
| `TEST_SALEOR_API_URL` | _required_ | Full URL to the Saleor GraphQL endpoint | `https://saleor.cloud/graphql/` |
| `TEST_SALEOR_APP_TOKEN` | _required_ | [AppToken](https://docs.saleor.io/docs/3.x/api-reference/apps/objects/app-token) | `3DZ7CbFTyPETthDixPtFpPysoKG4FP` |
| `TEST_SALEOR_APP_ID` | _required_ | [App.id](https://docs.saleor.io/docs/3.x/api-reference/apps/objects/app) | `QXBwOjk=` |
| `TEST_SALEOR_JWKS` | _required_ | stringified JWKS | `"{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"...\", \"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}"` |
| `TEST_PAYMENT_APP_SECRET_KEY` | _required_ | Secret Key from Stripe | `sk_test_51LVZwxEosE…` |
| `TEST_PAYMENT_APP_PUBLISHABLE_KEY` | _required_ | Publishable Key from Stripe | `pk_test_51LVZwxEos…` |
| `TEST_PAYMENT_APP_WEBHOOK_ID` | _required_ | ID of a webhook | `we_1JaGFlH1Vac4G4dbZnQ8bviV` |
| `TEST_PAYMENT_APP_WEBHOOK_SECRET` | _required_ | Webhook Secret from Stripe | `whsec_c09e3d87…` |
Then run the following command:
```bash
pnpm test
```

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Reporting a Vulnerability
If you discover a security vulnerability in saleor-app-payment-stripe please disclose it via [our huntr page](https://huntr.dev/repos/saleor/saleor-app-payment-stripe/). Information about bounties, CVEs, response times and past reports are all there.
Thank you for improving the security of saleor-app-payment-stripe.

22
fix-coverage-report.cjs Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
// https://github.com/ArtiomTr/jest-coverage-report-action/issues/244#issuecomment-1260555231
const fs = require("fs");
const testReportFilename = process.cwd() + "/coverage/report.json";
const coverageReportFilename = process.cwd() + "/coverage/coverage-final.json";
const testReport = require(testReportFilename);
const coverageReport = require(coverageReportFilename);
testReport.coverageMap = coverageReport;
fs.writeFile(testReportFilename, JSON.stringify(testReport), (err) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log("Coverage report appended to " + testReportFilename);
});

39
global.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
type NonFalsy<T> = T extends false | 0 | "" | null | undefined | 0n ? never : T;
interface Array<T> {
includes(searchElement: unknown, fromIndex?: number): searchElement is T;
}
interface ReadonlyArray<T> {
includes(searchElement: unknown, fromIndex?: number): searchElement is T;
}
interface Body {
json(): Promise<unknown>;
}
interface Array<T> {
filter(predicate: BooleanConstructor, thisArg?: unknown): NonFalsy<T>[];
}
interface ReadonlyArray<T> {
filter(predicate: BooleanConstructor, thisArg?: unknown): NonFalsy<T>[];
}
interface ArrayConstructor {
isArray(arg: unknown): arg is unknown[];
}
interface JSON {
/**
* Converts a JavaScript Object Notation (JSON) string into an object.
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of the object.
* If a member contains nested objects, the nested objects are transformed before the parent object is.
*/
parse(text: string, reviver?: (this: unknown, key: string, value: unknown) => unknown): unknown;
}
interface Set<T> {
has(value: unknown): value is T;
}

View File

View File

@@ -0,0 +1,4 @@
fragment Money on Money {
currency
amount
}

View File

@@ -0,0 +1,110 @@
fragment OrderOrCheckoutLines on OrderOrCheckout {
__typename
... on Checkout {
channel {
id
slug
}
shippingPrice {
gross {
...Money
}
net {
...Money
}
tax {
...Money
}
}
deliveryMethod {
__typename
... on ShippingMethod {
id
name
}
}
lines {
__typename
id
quantity
totalPrice {
gross {
...Money
}
net {
...Money
}
tax {
...Money
}
}
checkoutVariant: variant {
name
sku
product {
name
thumbnail {
url
}
category {
name
}
}
}
}
}
... on Order {
channel {
id
slug
}
shippingPrice {
gross {
...Money
}
net {
...Money
}
tax {
...Money
}
}
deliveryMethod {
__typename
... on ShippingMethod {
id
name
}
}
lines {
__typename
id
quantity
taxRate
totalPrice {
gross {
...Money
}
net {
...Money
}
tax {
...Money
}
}
orderVariant: variant {
name
sku
product {
name
thumbnail {
url
}
category {
name
}
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
fragment OrderOrCheckoutSourceObject on OrderOrCheckout {
__typename
... on Checkout {
id
languageCode
channel {
id
slug
}
userEmail: email
billingAddress {
...TransactionInitializeSessionAddress
}
shippingAddress {
...TransactionInitializeSessionAddress
}
total: totalPrice {
gross {
...Money
}
}
...OrderOrCheckoutLines
}
... on Order {
id
languageCodeEnum
userEmail
channel {
id
slug
}
billingAddress {
...TransactionInitializeSessionAddress
}
shippingAddress {
...TransactionInitializeSessionAddress
}
total {
gross {
...Money
}
}
...OrderOrCheckoutLines
}
}

View File

@@ -0,0 +1,5 @@
fragment PaymentGatewayInitializeSessionAddress on Address {
country {
code
}
}

View File

@@ -0,0 +1,49 @@
fragment PaymentGatewayInitializeSessionEvent on PaymentGatewayInitializeSession {
__typename
recipient {
...PaymentGatewayRecipient
}
data
amount
issuingPrincipal {
... on Node {
id
}
}
sourceObject {
__typename
... on Checkout {
id
channel {
id
slug
}
languageCode
billingAddress {
...PaymentGatewayInitializeSessionAddress
}
total: totalPrice {
gross {
...Money
}
}
}
... on Order {
id
channel {
id
slug
}
languageCodeEnum
userEmail
billingAddress {
...PaymentGatewayInitializeSessionAddress
}
total {
gross {
...Money
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fragment PaymentGatewayRecipient on App {
id
privateMetadata {
key
value
}
metadata {
key
value
}
}

View File

@@ -0,0 +1,20 @@
fragment TransactionCancelationRequestedEvent on TransactionCancelationRequested {
__typename
recipient {
...PaymentGatewayRecipient
}
action {
actionType
amount
}
transaction {
id
pspReference
sourceObject: order {
channel {
id
slug
}
}
}
}

View File

@@ -0,0 +1,24 @@
fragment TransactionChargeRequestedEvent on TransactionChargeRequested {
__typename
recipient {
...PaymentGatewayRecipient
}
action {
amount
actionType
}
transaction {
id
pspReference
sourceObject: order {
... on Order {
total {
gross {
...Money
}
}
}
...OrderOrCheckoutLines
}
}
}

View File

@@ -0,0 +1,14 @@
fragment TransactionInitializeSessionAddress on Address {
firstName
lastName
phone
city
streetAddress1
streetAddress2
postalCode
countryArea
companyName
country {
code
}
}

View File

@@ -0,0 +1,26 @@
fragment TransactionInitializeSessionEvent on TransactionInitializeSession {
__typename
recipient {
...PaymentGatewayRecipient
}
data
merchantReference
action {
amount
currency
actionType
}
issuingPrincipal {
... on Node {
id
}
}
transaction {
id
pspReference
}
sourceObject {
__typename
...OrderOrCheckoutSourceObject
}
}

View File

@@ -0,0 +1,54 @@
fragment TransactionProcessSessionEvent on TransactionProcessSession {
__typename
recipient {
...PaymentGatewayRecipient
}
data
merchantReference
action {
amount
currency
actionType
}
transaction {
id
pspReference
}
sourceObject {
__typename
... on Checkout {
id
languageCode
userEmail: email
billingAddress {
...TransactionInitializeSessionAddress
}
shippingAddress {
...TransactionInitializeSessionAddress
}
total: totalPrice {
gross {
...Money
}
}
...OrderOrCheckoutLines
}
... on Order {
id
languageCodeEnum
userEmail
billingAddress {
...TransactionInitializeSessionAddress
}
shippingAddress {
...TransactionInitializeSessionAddress
}
total {
gross {
...Money
}
}
...OrderOrCheckoutLines
}
}
}

View File

@@ -0,0 +1,24 @@
fragment TransactionRefundRequestedEvent on TransactionRefundRequested {
__typename
recipient {
...PaymentGatewayRecipient
}
action {
amount
actionType
}
transaction {
id
pspReference
sourceObject: order {
... on Order {
total {
gross {
...Money
}
}
}
...OrderOrCheckoutLines
}
}
}

View File

View File

@@ -0,0 +1,28 @@
mutation TransactionEventReport(
$transactionId: ID!
$amount: PositiveDecimal!
$availableActions: [TransactionActionEnum!]!
$externalUrl: String!
$message: String
$pspReference: String!
$time: DateTime!
$type: TransactionEventTypeEnum!
) {
transactionEventReport(
id: $transactionId
amount: $amount
availableActions: $availableActions
externalUrl: $externalUrl
message: $message
pspReference: $pspReference
time: $time
type: $type
) {
alreadyProcessed
errors {
field
message
code
}
}
}

View File

@@ -0,0 +1,10 @@
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}

View File

@@ -0,0 +1,10 @@
mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) {
updateMetadata(id: $id, input: $input) {
item {
metadata {
key
value
}
}
}
}

0
graphql/queries/.gitkeep Normal file
View File

View File

@@ -0,0 +1,16 @@
query FetchAppDetails {
shop {
schemaVersion
}
app {
id
privateMetadata {
key
value
}
metadata {
key
value
}
}
}

View File

@@ -0,0 +1,6 @@
query FetchChannels {
channels {
id
name
}
}

28869
graphql/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,5 @@
subscription PaymentGatewayInitializeSession {
event {
...PaymentGatewayInitializeSessionEvent
}
}

View File

@@ -0,0 +1,5 @@
subscription TransactionCancelationRequested {
event {
...TransactionCancelationRequestedEvent
}
}

View File

@@ -0,0 +1,5 @@
subscription TransactionChargeRequested {
event {
...TransactionChargeRequestedEvent
}
}

View File

@@ -0,0 +1,5 @@
subscription TransactionInitializeSession {
event {
...TransactionInitializeSessionEvent
}
}

View File

@@ -0,0 +1,5 @@
subscription TransactionProcessSession {
event {
...TransactionProcessSessionEvent
}
}

View File

@@ -0,0 +1,5 @@
subscription TransactionRefundRequested {
event {
...TransactionRefundRequestedEvent
}
}

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

31
next.config.mjs Normal file
View File

@@ -0,0 +1,31 @@
// @ts-check
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { createVanillaExtractPlugin } from "@vanilla-extract/next-plugin";
const withVanillaExtract = createVanillaExtractPlugin();
import { withSentryConfig } from "@sentry/nextjs";
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
!process.env.SKIP_ENV_VALIDATION && (await import("./src/lib/env.mjs"));
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
/** @param { import("webpack").Configuration } config */
webpack(config) {
config.experiments = { ...config.experiments, topLevelAwait: true };
return config;
},
};
const isSentryEnabled = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
const vanillaExtractConfig = withVanillaExtract(config);
export default isSentryEnabled
? withSentryConfig(vanillaExtractConfig, { silent: true }, { hideSourceMaps: true })
: vanillaExtractConfig;

152
package.json Normal file
View File

@@ -0,0 +1,152 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "saleor-app-payment-stripe",
"description": "Saleor App Payment Stripe is a payment integration app that allows merchants using the Saleor e-commerce platform to accept online payments from customers using Stripe as their payment processor.",
"version": "0.4.0",
"private": true,
"repository": "github:saleor/saleor-app-payment-stripe",
"homepage": "https://github.com/saleor/saleor-app-payment-stripe",
"bugs": {
"url": "https://github.com/saleor/saleor-app-payment-stripe/issues",
"email": "hello@saleor.io"
},
"type": "module",
"scripts": {
"dev": "pnpm generate && next dev",
"build": "pnpm generate && next build",
"deploy": "tsx ./src/deploy.ts",
"start": "next start",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "pnpm generate:graphql && pnpm generate:schema",
"generate:graphql": "graphql-codegen",
"generate:schema": "pnpm saleor-schema-compiler compile ./src/schemas --definitions ./src/schemas/definitions.json",
"lint": "next lint --fix --dir src",
"lint:ci": "next lint --dir src",
"test": "vitest",
"test:record": "POLLY_MODE=record_missing vitest",
"test:rerecord": "POLLY_MODE=record vitest",
"test:ci": "CI=true vitest --coverage --reporter=json --reporter=default && tsx fix-coverage-report.cjs",
"migrate": "pnpm tsx ./src/run-migrations.ts",
"ts-node-esm": "node --loader ts-node/esm --experimental-specifier-resolution=node",
"prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') throw e}\"",
"github:release": "pnpm changeset tag && git push --follow-tags"
},
"saleor": {
"schemaVersion": "3.13"
},
"dependencies": {
"@hookform/resolvers": "3.3.2",
"@next/env": "14.0.4",
"@radix-ui/react-alert-dialog": "1.0.5",
"@saleor/app-sdk": "0.47.2",
"@saleor/macaw-ui": "0.8.0-pre.123",
"@sentry/nextjs": "7.86.0",
"@t3-oss/env-nextjs": "0.7.1",
"@tanstack/react-query": "4",
"@tanstack/react-query-devtools": "4.36.1",
"@trpc/client": "10.44.1",
"@trpc/next": "10.44.1",
"@trpc/server": "10.44.1",
"@urql/exchange-auth": "1.0.0",
"@vanilla-extract/css": "1.14.0",
"@vanilla-extract/next-plugin": "2.3.2",
"@vanilla-extract/recipes": "0.5.1",
"@vitejs/plugin-react": "4.2.1",
"ajv": "8.12.0",
"ajv-formats": "2.1.1",
"bluebird": "3.7.2",
"classnames": "2.3.2",
"eslint-plugin-node": "11.1.0",
"graphql": "16.8.1",
"graphql-tag": "2.12.6",
"jose": "5.1.3",
"jsdom": "23.0.1",
"lodash-es": "4.17.21",
"modern-errors": "7.0.0",
"modern-errors-http": "5.0.0",
"modern-errors-serialize": "6.0.0",
"next": "14.2.10",
"omit-deep-lodash": "1.1.7",
"pino": "8.16.2",
"pino-pretty": "10.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.49.1",
"semver": "7.5.4",
"stripe": "14.8.0",
"stripe-event-types": "3.1.0",
"tsx": "4.6.2",
"url-join": "5.0.0",
"urql": "3.0.4",
"uuidv7": "0.6.3",
"webpack": "5.94.0",
"yup": "1.3.2",
"zod": "3.22.4",
"zustand": "4.4.7"
},
"devDependencies": {
"@changesets/cli": "2.27.1",
"@graphql-codegen/add": "5.0.0",
"@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/introspection": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql": "4.0.0",
"@graphql-codegen/urql-introspection": "3.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@pollyjs/adapter-fetch": "6.0.6",
"@pollyjs/adapter-node-http": "6.0.6",
"@pollyjs/core": "6.0.6",
"@pollyjs/persister-fs": "6.0.6",
"@saleor/eslint-plugin-saleor-app": "0.1.2",
"@saleor/json-schema-compiler": "0.1.2",
"@testing-library/jest-dom": "6.1.5",
"@testing-library/react": "14.1.2",
"@testing-library/react-hooks": "8.0.1",
"@types/bluebird": "3.5.42",
"@types/lodash-es": "4.17.12",
"@types/node": "20.10.4",
"@types/omit-deep-lodash": "1.1.3",
"@types/react": "18.2.45",
"@types/react-dom": "18.2.17",
"@types/semver": "7.5.6",
"@types/setup-polly-jest": "0.5.5",
"@typescript-eslint/eslint-plugin": "6.14.0",
"@typescript-eslint/parser": "6.14.0",
"@vanilla-extract/vite-plugin": "3.9.3",
"@vitest/coverage-v8": "0.34.2",
"vite": "5.0.8",
"dependency-cruiser": "15.5.0",
"eslint": "8.55.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-require-form-method": "1.0.2",
"eslint-plugin-testing-library": "6.2.0",
"eslint-plugin-vitest": "0.3.16",
"husky": "8.0.3",
"json-schema-to-typescript": "13.1.1",
"lint-staged": "15.2.0",
"next-test-api-route-handler": "3.1.10",
"prettier": "3.1.1",
"setup-polly-jest": "0.11.0",
"ts-node": "10.9.2",
"typescript": "5.3.3",
"vite-tsconfig-paths": "4.2.2",
"vitest": "0.34.2"
},
"engines": {
"npm": ">=8.0.0 <10.0.0",
"node": "^20.0.0 || ^22.0.0",
"pnpm": "~8.12.0"
},
"packageManager": "pnpm@8.12.0",
"pnpm": {
"overrides": {
"@urql/exchange-auth>@urql/core": "3.2.2",
"@typescript-eslint/parser": "$@typescript-eslint/parser",
"vite": "$vite"
}
}
}

12008
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/icons/icon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

18
sentry.client.config.js Normal file
View File

@@ -0,0 +1,18 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { env } from "./src/lib/env.mjs";
const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

16
sentry.edge.config.js Normal file
View File

@@ -0,0 +1,16 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever middleware or an Edge route handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { env } from "./src/lib/env.mjs";
Sentry.init({
dsn: env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

4
sentry.properties Normal file
View File

@@ -0,0 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=saleor
defaults.project=
cli.executable=

18
sentry.server.config.js Normal file
View File

@@ -0,0 +1,18 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { env } from "./src/lib/env.mjs";
const SENTRY_DSN = env.SENTRY_DSN || env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View File

@@ -0,0 +1,20 @@
import { type HttpMethod } from "../lib/api-response";
import { type JSONValue } from "../types";
export const host = "https://localhost:4213";
export const createRequestMock = (
method: HttpMethod,
body?: JSONValue,
headers = new Headers(),
) => {
if (body) {
headers.append("Content-Type", "application/json");
}
return new Request(`${host}/api/route`, {
method,
headers,
...(body && { body: JSON.stringify(body) }),
});
};

View File

@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { expect, vi, describe, it } from "vitest";
import IndexPage from "../../pages";
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge: () => ({
appBridgeState: {},
appBridge: {},
}),
};
});
vi.mock("next/router", () => ({
useRouter: vi.fn(),
}));
describe("App", () => {
it("renders text", () => {
render(<IndexPage />);
expect(
screen.getByText("Install this app in your Saleor Dashboard", { exact: false }),
).toBeInTheDocument();
});
});

269
src/__tests__/polly.ts Normal file
View File

@@ -0,0 +1,269 @@
import path from "path";
import NodeHttpAdapter from "@pollyjs/adapter-node-http";
import FetchAdapter from "@pollyjs/adapter-fetch";
import { Polly, type Headers, type PollyConfig } from "@pollyjs/core";
import FSPersister from "@pollyjs/persister-fs";
import { afterEach, beforeEach, expect } from "vitest";
import merge from "lodash-es/merge";
import omit from "lodash-es/omit";
import omitDeep from "omit-deep-lodash";
import { tryJsonParse, tryIgnore } from "../lib/utils";
import { testEnv } from "@/__tests__/test-env.mjs";
import { env } from "@/lib/env.mjs";
declare module "vitest" {
export interface TestContext {
polly?: Polly;
}
}
export const omitPathsFromJson = (paths: string[]) => (input: string) => {
return JSON.stringify(omit(JSON.parse(input) as object, paths));
};
export const omitPathsFromHeaders = (paths: string[]) => (headers: Headers) => {
return omit(headers, paths);
};
const HEADERS_BLACKLIST = new Set([
"authorization-bearer",
"authorization",
"saleor-signature",
"set-cookie",
"x-api-key",
"x-stripe-client-user-agent",
]);
const VARIABLES_BLACKLIST = new Set([
"csrfToken",
"email",
"newEmail",
"newPassword",
"oldPassword",
"password",
"redirectUrl",
"refreshToken",
"token",
"authorisationToken",
]);
const removeBlacklistedVariables = (
obj: {} | undefined | string | null,
): {} | undefined | string | null => {
if (!obj || typeof obj === "string") {
return obj;
}
if ("client_secret" in obj) {
obj.client_secret = "pi_FAKE_CLIENT_SECRET";
}
return omitDeep(obj, ...VARIABLES_BLACKLIST);
};
/**
* This interface is incomplete
*/
interface PollyRecording {
response: {
content?: {
mimeType: string;
text: string;
};
cookies: string[];
headers: Array<{ value: string; name: string }>;
};
request: {
postData?: {
text: string;
};
cookies: string[];
headers: Array<{ value: string; name: string }>;
};
}
const responseIsJson = (recording: PollyRecording) => {
return recording.response.content?.mimeType.includes("application/json");
};
const requestIsJson = (recording: PollyRecording) => {
return recording.request.headers.some(
({ name, value }) =>
name.toLowerCase() === "content-type" && value.includes("application/json"),
);
};
export const setupRecording = (config?: PollyConfig) => {
Polly.on("create", (polly) => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
polly.server
.any()
// Hide sensitive data in headers or in body
.on("beforePersist", (_req, recording: PollyRecording) => {
recording.response.cookies = [];
recording.response.headers = recording.response.headers.filter(
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
);
recording.request.headers = recording.request.headers.filter(
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
);
if (recording.request.postData?.text) {
const requestJson = tryJsonParse(recording.request.postData.text);
const filteredRequestJson = removeBlacklistedVariables(requestJson);
recording.request.postData.text =
typeof filteredRequestJson === "string"
? filteredRequestJson
: JSON.stringify(filteredRequestJson);
}
if (recording.response.content?.text) {
const responseJson = tryJsonParse(recording.response.content.text);
const filteredResponseJson = removeBlacklistedVariables(responseJson);
recording.response.content.text =
typeof filteredResponseJson === "string"
? filteredResponseJson
: JSON.stringify(filteredResponseJson);
}
})
// make JSON response and requests more readable
// https://github.com/Netflix/pollyjs/issues/322
.on("beforePersist", (_req, recording: PollyRecording) => {
if (responseIsJson(recording)) {
tryIgnore(
() =>
(recording.response.content!.text = JSON.parse(
recording.response.content!.text,
) as string),
);
}
if (requestIsJson(recording)) {
tryIgnore(
() =>
(recording.request.postData!.text = JSON.parse(
recording.request.postData!.text,
) as string),
);
}
})
.on("beforeReplay", (_req, recording: PollyRecording) => {
if (responseIsJson(recording)) {
tryIgnore(
() =>
(recording.response.content!.text = JSON.stringify(recording.response.content!.text)),
);
}
if (requestIsJson(recording)) {
tryIgnore(
() =>
(recording.request.postData!.text = JSON.stringify(recording.request.postData!.text)),
);
}
});
/* eslint-enable */
});
const defaultConfig = {
...getRecordingSettings(),
adapters: [FetchAdapter, NodeHttpAdapter],
persister: FSPersister,
adapterOptions: {
fetch: {
context: globalThis,
},
},
persisterOptions: {
fs: {},
keepUnusedRequests: false,
},
flushRequestsOnStop: true,
matchRequestsBy: {
url: {
protocol: true,
username: true,
password: true,
hostname: true,
port: true,
pathname: true,
query: true,
hash: false,
},
body: true,
order: false,
method: true,
headers: {
exclude: [
"date",
"idempotency-key",
"original-request",
"request-id",
"content-length",
"x-stripe-client-user-agent",
],
},
},
};
beforeEach(async (ctx) => {
const { currentTestName } = expect.getState();
if (!currentTestName) {
throw new Error("This function must be run inside a test case!");
}
const recordingsRoot = path.dirname(expect.getState().testPath || "");
const recordingsDirectory = path.join(recordingsRoot, "__recordings__");
const [, ...names] = currentTestName.split(" > ");
const polly = new Polly(
names.join("/"),
merge(
defaultConfig,
{
persisterOptions: {
fs: {
recordingsDir: recordingsDirectory,
},
},
},
config,
),
);
ctx.polly = polly;
});
afterEach((ctx) => ctx.polly?.flush());
afterEach((ctx) => ctx.polly?.stop());
};
const getRecordingSettings = (): Pick<
PollyConfig,
"mode" | "recordIfMissing" | "recordFailedRequests"
> => {
// use replay mode by default, override if POLLY_MODE env variable is passed
const mode = env.CI ? "replay" : testEnv.POLLY_MODE;
if (mode === "record") {
return {
mode: "record",
recordIfMissing: true,
recordFailedRequests: true,
};
}
if (mode === "record_missing") {
return {
mode: "replay",
recordIfMissing: true,
recordFailedRequests: true,
};
}
return {
mode: "replay",
recordIfMissing: false,
recordFailedRequests: false,
};
};

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
const testEnvSchema = z.object({
// Saleor
TEST_SALEOR_API_URL: z.string().url(),
TEST_SALEOR_APP_TOKEN: z.string(),
TEST_SALEOR_APP_ID: z.string(),
TEST_SALEOR_JWKS: z.string(),
// Payment App
TEST_PAYMENT_APP_SECRET_KEY: z.string(),
TEST_PAYMENT_APP_PUBLISHABLE_KEY: z.string(),
TEST_PAYMENT_APP_WEBHOOK_SECRET: z.string(),
TEST_PAYMENT_APP_WEBHOOK_ID: z.string(),
// Polly.js
POLLY_MODE: z.enum(["record", "record_missing", "replay"]).optional().default("replay"),
});
const processEnv = {
// Saleor
TEST_SALEOR_API_URL: process.env.TEST_SALEOR_API_URL,
TEST_SALEOR_APP_TOKEN: process.env.TEST_SALEOR_APP_TOKEN,
TEST_SALEOR_APP_ID: process.env.TEST_SALEOR_APP_ID,
TEST_SALEOR_JWKS: process.env.TEST_SALEOR_JWKS,
// Payment App
TEST_PAYMENT_APP_SECRET_KEY: process.env.TEST_PAYMENT_APP_SECRET_KEY,
TEST_PAYMENT_APP_PUBLISHABLE_KEY: process.env.TEST_PAYMENT_APP_PUBLISHABLE_KEY,
TEST_PAYMENT_APP_WEBHOOK_SECRET: process.env.TEST_PAYMENT_APP_WEBHOOK_SECRET,
TEST_PAYMENT_APP_WEBHOOK_ID: process.env.TEST_PAYMENT_APP_WEBHOOK_ID,
// Polly.js
POLLY_MODE: process.env.POLLY_MODE,
};
/* c8 ignore start */
/** @type z.infer<testEnvSchema>
* @ts-ignore - can't type this properly in jsdoc */
// eslint-disable-next-line import/no-mutable-exports
let testEnv = process.env;
if (!!process.env.SKIP_ENV_VALIDATION == false) {
const parsed = testEnvSchema.safeParse(processEnv);
if (parsed.success === false) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors);
throw new Error("Invalid environment variables");
}
testEnv = parsed.data;
}
/* c8 ignore stop */
export { testEnv };

59
src/__tests__/testAPL.ts Normal file
View File

@@ -0,0 +1,59 @@
import {
type AplConfiguredResult,
type AplReadyResult,
type APL,
type AuthData,
} from "@saleor/app-sdk/APL";
import { testEnv } from "@/__tests__/test-env.mjs";
export class TestAPL implements APL {
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
if (testEnv.TEST_SALEOR_API_URL !== saleorApiUrl) {
return;
}
return {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
jwks: "",
appId: testEnv.TEST_SALEOR_APP_ID,
token: testEnv.TEST_SALEOR_APP_TOKEN,
};
}
async set(authData: AuthData) {
console.warn("Attempted to save APL authData in test", authData);
}
async delete() {
console.warn("Attempted to delete APL authData in test");
}
async getDomain(): Promise<string | undefined> {
return new URL(testEnv.TEST_SALEOR_API_URL).hostname;
}
async isReady(): Promise<AplReadyResult> {
return {
ready: true,
};
}
async getAll() {
return [
{
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
jwks: "",
appId: testEnv.TEST_SALEOR_APP_ID,
token: testEnv.TEST_SALEOR_APP_TOKEN,
},
];
}
async isConfigured(): Promise<AplConfiguredResult> {
return {
configured: true,
};
}
}

View File

@@ -0,0 +1,7 @@
import { AppBridge } from "@saleor/app-sdk/app-bridge";
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;

View File

@@ -0,0 +1,294 @@
import { describe, it, expect, vi } from "vitest";
import { type ValidateFunction } from "ajv";
import { type NextApiResponse, type NextApiRequest } from "next/types";
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { getAuthDataForRequest, getSyncWebhookHandler, validateData } from "./api-route-utils";
import { testEnv } from "@/__tests__/test-env.mjs";
import { BaseError, MissingSaleorApiUrlError } from "@/errors";
describe("api-route-utils", () => {
describe("validateData", () => {
it("should return data if it validates", async () => {
const data = { a: 1, b: "c" };
await expect(
validateData(data, (() => true) as unknown as ValidateFunction),
).resolves.toEqual(data);
});
it("should throw error if it doesn't validate", async () => {
const data = { a: 1, b: "c" };
await expect(
validateData(data, (() => false) as unknown as ValidateFunction),
).rejects.toMatchInlineSnapshot("[UnknownError: JsonSchemaError]");
});
it("should throw error if it throws", async () => {
const data = { a: 1, b: "c" };
await expect(
validateData(data, (() => {
throw new Error("some error");
}) as unknown as ValidateFunction),
).rejects.toMatchInlineSnapshot("[UnknownError: some error]");
});
});
describe("getSyncWebhookHandler", () => {
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
it("should return a function", () => {
expect(
getSyncWebhookHandler(
"TestWebhook",
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
(() => {}) as any,
(() => true) as unknown as ValidateFunction,
() => ({}),
),
).toEqual(expect.any(Function));
});
it("calls handler with payload and saleorApiUrl from context", async () => {
const handler = vi.fn();
const webhookHandler = getSyncWebhookHandler(
"TestWebhook",
handler,
(() => true) as unknown as ValidateFunction,
() => ({}),
);
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
const payload = {};
const authData = {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
};
await webhookHandler(
{} as NextApiRequest,
{ json() {} } as unknown as NextApiResponse,
{
authData,
payload,
} as unknown as WebhookContext,
);
expect(handler).toHaveBeenCalledWith(payload, authData.saleorApiUrl);
});
it("returns json with result", async () => {
const handler = vi.fn().mockReturnValue({
some: "json",
});
const json = vi.fn();
const webhookHandler = getSyncWebhookHandler(
"TestWebhook",
handler,
(() => true) as unknown as ValidateFunction,
() => ({}),
);
const payload = {};
const authData = {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
};
await webhookHandler(
{} as NextApiRequest,
{ json } as unknown as NextApiResponse,
{
authData,
payload,
} as unknown as WebhookContext,
);
expect(json).toHaveBeenCalledWith({
some: "json",
});
});
it("catches known errors and returns 200 with details", async () => {
const handler = vi.fn().mockImplementation(() => {
throw new BaseError("This is a known error", {
props: {
errorCode: 123,
statusCode: 422,
},
errors: [new Error("Initial problem")],
});
});
const errorMapper = vi.fn();
const json = vi.fn();
const status = vi.fn().mockReturnValue({ json });
const webhookHandler = getSyncWebhookHandler(
"TestWebhook",
handler,
(() => true) as unknown as ValidateFunction,
errorMapper,
);
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
const payload = {};
const authData = {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
};
await webhookHandler(
{} as NextApiRequest,
{ json, status } as unknown as NextApiResponse,
{
authData,
payload,
} as unknown as WebhookContext,
);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalled();
expect(errorMapper.mock.lastCall[1]).toMatchInlineSnapshot(`
{
"errors": [
{
"code": "BaseError",
"details": {},
"message": "This is a known error",
},
{
"code": "Error",
"message": "Initial problem",
},
],
"message": "This is a known error",
"sentry": [
[BaseError: This is a known error],
{
"extra": {
"errors": [
[Error: Initial problem],
],
},
},
],
}
`);
});
it("catches known errors and responds with whatever the errorMapper returns", async () => {
const handler = vi.fn().mockImplementation(() => {
throw new MissingSaleorApiUrlError("Missing");
});
const errorMapper = vi.fn().mockImplementation((payload, error) => {
return {
errors: error.errors,
message: error.message,
};
});
const json = vi.fn();
const status = vi.fn().mockReturnValue({ json });
const webhookHandler = getSyncWebhookHandler(
"TestWebhook",
handler,
(() => true) as unknown as ValidateFunction,
errorMapper,
);
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
const payload = {};
const authData = {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
};
await webhookHandler(
{} as NextApiRequest,
{ json, status } as unknown as NextApiResponse,
{
authData,
payload,
} as unknown as WebhookContext,
);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalled();
expect(json.mock.lastCall[0]).toMatchInlineSnapshot(`
{
"errors": [
{
"code": "MissingSaleorApiUrlError",
"details": {},
"message": "Missing",
},
],
"message": "Missing",
}
`);
});
it("catches unknown errors and returns 500", async () => {
const handler = vi.fn().mockImplementation(() => {
throw new Error("Some error");
});
const json = vi.fn();
const status = vi.fn().mockReturnValue({ json });
const webhookHandler = getSyncWebhookHandler(
"TestWebhook",
handler,
(() => true) as unknown as ValidateFunction,
() => ({}),
);
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
const payload = {};
const authData = {
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
};
await webhookHandler(
{} as NextApiRequest,
{ json, status } as unknown as NextApiResponse,
{
authData,
payload,
} as unknown as WebhookContext,
);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalled();
expect(BaseError.normalize(json.mock.lastCall[0])).toMatchInlineSnapshot(
"[BaseError: Some error]",
);
});
});
describe("getAuthDataForRequest", () => {
it("should throw if there's no saleroApiUrl in the query", async () => {
await expect(
getAuthDataForRequest({ query: {} } as NextApiRequest),
).rejects.toMatchInlineSnapshot(
"[MissingSaleorApiUrlError: Missing saleorApiUrl query param]",
);
});
it("should throw if data doesn't exist in APL", async () => {
await expect(
getAuthDataForRequest({ query: { saleorApiUrl: "someurl" } } as unknown as NextApiRequest),
).rejects.toMatchInlineSnapshot("[MissingAuthDataError: APL for someurl not found]");
});
it("should return data from apl if it exists", async () => {
await expect(
getAuthDataForRequest({
query: { saleorApiUrl: testEnv.TEST_SALEOR_API_URL },
} as unknown as NextApiRequest),
).resolves.toMatchInlineSnapshot(`
{
"appId": "123456",
"domain": "saleor.localhost",
"jwks": "",
"saleorApiUrl": "https://saleor.localhost:8080/graphql/",
"token": "123456",
}
`);
});
});
});

View File

@@ -0,0 +1,128 @@
import * as Sentry from "@sentry/nextjs";
import { type NextApiRequest, type NextApiResponse } from "next";
import type { ValidateFunction } from "ajv";
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { createLogger, redactError } from "../lib/logger";
import {
JsonSchemaError,
UnknownError,
BaseError,
MissingAuthDataError,
MissingSaleorApiUrlError,
} from "@/errors";
import { toStringOrEmpty } from "@/lib/utils";
import { saleorApp } from "@/saleor-app";
export const validateData = async <S extends ValidateFunction>(data: unknown, validate: S) => {
type Result = S extends ValidateFunction<infer T> ? T : never;
try {
const isValid = validate(data);
if (!isValid) {
throw JsonSchemaError.normalize(validate.errors);
}
return data as Result;
} catch (err) {
throw UnknownError.normalize(err);
}
};
export function getSyncWebhookHandler<TPayload, TResult, TSchema extends ValidateFunction<TResult>>(
name: string,
webhookHandler: (payload: TPayload, saleorApiUrl: string) => Promise<TResult>,
ResponseSchema: TSchema,
errorMapper: (payload: TPayload, errorResponse: ErrorResponse) => TResult & {},
): NextWebhookApiHandler<TPayload> {
return async (_req, res: NextApiResponse<Error | TResult>, ctx) => {
const logger = createLogger(
{
event: ctx.event,
},
{ msgPrefix: `[${name}] ` },
);
const { authData, payload } = ctx;
logger.info(`handler called: ${webhookHandler.name}`);
logger.debug({ payload }, "ctx payload");
try {
const result = await webhookHandler(payload, authData.saleorApiUrl);
logger.info(`${webhookHandler.name} was successful`);
logger.debug({ result }, "Sending successful response");
return res.json(await validateData(result, ResponseSchema));
} catch (err) {
logger.error({ err: redactError(err) }, `${webhookHandler.name} error`);
const response = errorToResponse(err);
if (!response) {
Sentry.captureException(err);
const result = BaseError.serialize(err);
logger.debug("Sending error response");
return res.status(500).json(result);
}
Sentry.captureException(...response.sentry);
const finalErrorResponse = errorMapper(payload, response);
logger.debug({ finalErrorResponse }, "Sending error response");
return res.status(200).json(await validateData(finalErrorResponse, ResponseSchema));
}
};
}
type ErrorResponse = Exclude<ReturnType<typeof errorToResponse>, null>;
const errorToResponse = (err: unknown) => {
const normalizedError = err instanceof BaseError ? err : null;
if (!normalizedError) {
return null;
}
const sentry = [
normalizedError,
{
extra: {
errors: normalizedError.errors,
},
},
] as const;
const message = normalizedError.message;
const errors = [
{
code: normalizedError.name,
message: normalizedError.message,
details: {},
},
...(normalizedError.errors?.map((inner) => {
return {
code: inner.name,
message: inner.message,
};
}) ?? []),
];
return {
sentry,
errors,
message,
};
};
export const getAuthDataForRequest = async (request: NextApiRequest) => {
const logger = createLogger({}, { msgPrefix: "[getAuthDataForRequest] " });
const saleorApiUrl = toStringOrEmpty(request.query.saleorApiUrl);
logger.info(`Got saleorApiUrl=${saleorApiUrl || "<undefined>"}`);
if (!saleorApiUrl) {
throw new MissingSaleorApiUrlError("Missing saleorApiUrl query param");
}
const authData = await saleorApp.apl.get(saleorApiUrl);
logger.debug({ authData });
if (!authData) {
throw new MissingAuthDataError(`APL for ${saleorApiUrl} not found`);
}
return authData;
};

12
src/deploy.ts Normal file
View File

@@ -0,0 +1,12 @@
/* eslint-disable node/no-process-env */
import { execSync } from "child_process";
execSync("pnpm run build", { stdio: "inherit" });
if (
process.env.DEPLOYMENT_ENVIRONMENT === "production" ||
process.env.DEPLOYMENT_ENVIRONMENT === "staging"
) {
console.log("Production environment detected, running migrations");
execSync("pnpm run migrate", { stdio: "inherit" });
}

79
src/errors.ts Normal file
View File

@@ -0,0 +1,79 @@
import { type IncomingHttpHeaders } from "node:http2";
import { type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
import ModernError from "modern-errors";
import ModernErrorsSerialize from "modern-errors-serialize";
// Http errors
type CommonProps = {
errorCode?: string;
statusCode?: number;
name?: number;
};
export const BaseError = ModernError.subclass("BaseError", {
plugins: [ModernErrorsSerialize],
props: {} as CommonProps,
});
export const UnknownError = BaseError.subclass("UnknownError");
export const JsonSchemaError = BaseError.subclass("JsonSchemaError");
export const MissingSaleorApiUrlError = BaseError.subclass("MissingSaleorApiUrlError");
export const MissingAuthDataError = BaseError.subclass("MissingAuthDataError");
export const HttpRequestError = BaseError.subclass("HttpRequestError", {
props: {} as { statusCode: number; body: string; headers: IncomingHttpHeaders },
});
// TRPC Errors
export interface TrpcErrorOptions {
/** HTTP response code returned by TRPC */
trpcCode?: TRPC_ERROR_CODE_KEY;
}
export const BaseTrpcError = BaseError.subclass("BaseTrpcError", {
props: { trpcCode: "INTERNAL_SERVER_ERROR" } as TrpcErrorOptions,
});
export const JwtTokenExpiredError = BaseTrpcError.subclass("JwtTokenExpiredError", {
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
});
export const JwtInvalidError = BaseTrpcError.subclass("JwtInvalidError", {
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
});
export const ReqMissingSaleorApiUrlError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
});
export const ReqMissingAuthDataError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
});
export const ReqMissingTokenError = BaseTrpcError.subclass("ReqMissingTokenError", {
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
});
export const ReqMissingAppIdError = BaseTrpcError.subclass("ReqMissingAppIdError", {
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
});
// TRPC + react-hook-form errors
export interface FieldErrorOptions extends TrpcErrorOptions {
fieldName: string;
}
export const FieldError = BaseTrpcError.subclass("FieldError", {
props: {} as FieldErrorOptions,
});
export const RestrictedKeyNotSupportedError = FieldError.subclass(
"RestrictedKeyNotSupportedError",
{
props: { fieldName: "secretKey" } as FieldErrorOptions,
},
);
export const InvalidSecretKeyError = FieldError.subclass("InvalidSecretKeyError", {
props: { fieldName: "secretKey" } as FieldErrorOptions,
});
export const UnexpectedSecretKeyError = FieldError.subclass("UnexpectedSecretKeyError", {
props: { fieldName: "secretKey" } as FieldErrorOptions,
});
export const InvalidPublishableKeyError = FieldError.subclass("InvalidPublishableKeyError", {
props: { fieldName: "publishableKey" } as FieldErrorOptions,
});
export const UnexpectedPublishableKeyError = FieldError.subclass("UnexpectedPublishableKeyError", {
props: { fieldName: "publishableKey" } as FieldErrorOptions,
});
export const FileReaderError = BaseError.subclass("FileReaderError");

71
src/lib/api-response.ts Normal file
View File

@@ -0,0 +1,71 @@
import { type JSONValue } from "../types";
export const getResponse =
(status: number) =>
(data: JSONValue, headers: Headers = new Headers()) => {
if (data) {
headers.append("Content-Type", "application/json");
}
return new Response(JSON.stringify(data), {
status,
headers,
});
};
export const HttpStatus = {
OK: 200,
Created: 201,
Accepted: 202,
NonAuthoritativeInformation: 203,
NoContent: 204,
ResetContent: 205,
PartialContent: 206,
BadRequest: 400,
Unauthorized: 401,
PaymentRequired: 402,
Forbidden: 403,
NotFound: 404,
MethodNotAllowed: 405,
NotAcceptable: 406,
ProxyAuthenticationRequired: 407,
RequestTimeout: 408,
Conflict: 409,
Gone: 410,
LengthRequired: 411,
PreconditionFailed: 412,
PayloadTooLarge: 413,
URITooLong: 414,
UnsupportedMediaType: 415,
RangeNotSatisfiable: 416,
ExpectationFailed: 417,
ImaTeapot: 418,
MisdirectedRequest: 421,
UnprocessableEntity: 422,
Locked: 423,
FailedDependency: 424,
TooEarly: 425,
UpgradeRequired: 426,
PreconditionRequired: 428,
TooManyRequests: 429,
RequestHeaderFieldsTooLarge: 431,
UnavailableForLegalReasons: 451,
InternalServerError: 500,
} as const;
export type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];
export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "HEAD";
export const ok = getResponse(HttpStatus.OK);
export const created = getResponse(HttpStatus.Created);
export const noContent = getResponse(HttpStatus.NoContent);
export const badRequest = getResponse(HttpStatus.BadRequest);
export const unauthorized = getResponse(HttpStatus.Unauthorized);
export const forbidden = getResponse(HttpStatus.Forbidden);
export const notFound = getResponse(HttpStatus.NotFound);
export const methodNotAllowed = (methods: string[]) =>
getResponse(HttpStatus.MethodNotAllowed)(
"Method not allowed",
new Headers({ Allow: methods.join(", ") }),
);
export const conflict = getResponse(HttpStatus.Conflict);

View File

@@ -0,0 +1,52 @@
import { type AuthConfig, authExchange } from "@urql/exchange-auth";
import {
cacheExchange,
createClient as urqlCreateClient,
dedupExchange,
fetchExchange,
} from "urql";
interface IAuthState {
token: string;
}
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
});
export function createServerClient(saleorApiUrl: string, token: string) {
return createClient(saleorApiUrl, async () => Promise.resolve({ token }));
}

62
src/lib/env.mjs Normal file
View File

@@ -0,0 +1,62 @@
// @ts-check
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
isServer: typeof window === "undefined" || process.env.NODE_ENV === "test",
/*
* Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
ENV: z.enum(["development", "test", "staging", "production"]).default("development"),
SECRET_KEY: z.string().min(8, { message: "Cannot be too short" }),
SENTRY_DSN: z.string().min(1).optional(),
APL: z.enum(["saleor-cloud", "upstash", "file"]).optional().default("file"),
CI: z.coerce.boolean().optional().default(false),
APP_DEBUG: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.optional()
.default("error"),
VERCEL_URL: z.string().optional(),
PORT: z.coerce.number().optional(),
UPSTASH_URL: z.string().optional(),
UPSTASH_TOKEN: z.string().optional(),
REST_APL_ENDPOINT: z.string().optional(),
REST_APL_TOKEN: z.string().optional(),
ALLOWED_DOMAIN_PATTERN: z.string().optional(),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {
NEXT_PUBLIC_SENTRY_DSN: z.optional(z.string().min(1)),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
ENV: process.env.ENV,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
SECRET_KEY: process.env.SECRET_KEY,
SENTRY_DSN: process.env.SENTRY_DSN,
APL: process.env.APL,
CI: process.env.CI,
APP_DEBUG: process.env.APP_DEBUG,
VERCEL_URL: process.env.VERCEL_URL,
PORT: process.env.PORT,
UPSTASH_URL: process.env.UPSTASH_URL,
UPSTASH_TOKEN: process.env.UPSTASH_TOKEN,
REST_APL_ENDPOINT: process.env.REST_APL_ENDPOINT,
REST_APL_TOKEN: process.env.REST_APL_TOKEN,
ALLOWED_DOMAIN_PATTERN: process.env.ALLOWED_DOMAIN_PATTERN,
},
});

View File

@@ -0,0 +1,8 @@
// Copied from @saleor/app-sdk/gql-ast-to-string.ts
import { type ASTNode, print } from "graphql";
export const gqlAstToString = (ast: ASTNode) =>
print(ast) // convert AST to string
.replaceAll(/\n+/g, " ") // remove new lines
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
.trim(); // remove whitespace from beginning and end

13
src/lib/invariant.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { invariant } from "./invariant";
describe("invariant", () => {
it.each([0, "", null, undefined, 0n])("should throw for %p", (value) => {
expect(() => invariant(value)).toThrowError("Invariant failed: ");
expect(() => invariant(value, "some message")).toThrowError("Invariant failed: some message");
});
it.each([true, 1, "some str", {}, [], 123n])("should not throw for %p", (value) => {
expect(() => invariant(value)).not.toThrowError();
});
});

20
src/lib/invariant.ts Normal file
View File

@@ -0,0 +1,20 @@
import ModernError from "modern-errors";
export const InvariantError = ModernError.subclass("InvariantError");
export function invariant(condition: unknown, message?: string): asserts condition {
if (!condition) {
const err = new InvariantError(`Invariant failed: ${message || ""}`);
// remove utils.js from stack trace for better error messages
const stack = (err.stack ?? "").split("\n");
stack.splice(1, 1);
err.stack = stack.join("\n");
throw err;
}
}
/* c8 ignore start */
export function assertUnreachableButNotThrow(_: never) {
return null as never;
}
/* c8 ignore stop */

4
src/lib/isEnv.ts Normal file
View File

@@ -0,0 +1,4 @@
/* eslint-disable node/no-process-env */
export const isTest = () => process.env.NODE_ENV === "test";
export const isDevelopment = () => process.env.NODE_ENV === "development";
export const isProduction = () => process.env.NODE_ENV === "production";

103
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,103 @@
// We have to use process.env, otherwise pino doesn't work
/* eslint-disable node/no-process-env */
import pino from "pino";
// import pinoPretty from "pino-pretty";
// import { isDevelopment, isTest } from "./isEnv";
import { isObject } from "./utils";
import { obfuscateValue } from "@/modules/app-configuration/utils";
import { BaseError, BaseTrpcError } from "@/errors";
/* c8 ignore start */
export const logger = pino({
level: process.env.APP_DEBUG ?? "info",
redact: {
paths: ["secretKey", "*[*].secretKey"],
censor: (value) => redactLogValue(value),
},
// transport:
// process.env.CI || isDevelopment() || isTest()
// ? {
// target: "pino-pretty",
// options: {
// colorize: true,
// },
// }
// : undefined,
});
/* c8 ignore stop */
export const createLogger = logger.child.bind(logger);
export const redactLogValue = (value: unknown) => {
if (typeof value !== "string") {
// non-string values are fully redacted to prevent leaks
return "[REDACTED]";
}
return obfuscateValue(value);
};
export const redactError = (error: unknown) => {
if (error instanceof BaseTrpcError) {
const { message, name, errorCode, statusCode, trpcCode } = error;
return {
message,
name,
errorCode,
statusCode,
trpcCode,
};
}
if (error instanceof BaseError) {
const { message, name, errorCode, statusCode } = error;
return {
message,
name,
errorCode,
statusCode,
};
}
if (error instanceof Error) {
const { message, name } = error;
return {
message,
name,
};
}
};
export const redactLogObject = <T extends {}>(obj: T, callCount = 1): T => {
if (callCount > 10) {
logger.warn("Exceeded max call count for redactLogObject");
return { _message: "[REDACTED - MAX CALL COUNT EXCEEDED]" } as unknown as T;
}
const entries = Object.entries(obj).map(([key, value]) => {
if (isObject(value)) {
return [key, redactLogObject(value, callCount + 1)];
}
if (Array.isArray(value)) {
return [key, redactLogArray(value)];
}
return [key, redactLogValue(value)];
});
return Object.fromEntries(entries) as T;
};
export const redactLogArray = <T extends unknown[]>(array: T | undefined, callCount = 1): T => {
if (!array) return [] as unknown as T;
if (callCount > 10) {
logger.warn("Exceeded max call count for redactLogArray");
return [] as unknown as T;
}
return array.map((item) => {
if (isObject(item)) {
return redactLogObject(item, callCount + 1);
}
if (Array.isArray(item)) {
return redactLogArray(item, callCount + 1);
}
return redactLogValue(item);
}) as T;
};

119
src/lib/use-fetch.ts Normal file
View File

@@ -0,0 +1,119 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import ModernError from "modern-errors";
import { useCallback, useEffect, useRef } from "react";
import { type z } from "zod";
import { type JSONValue } from "../types";
import { tryJsonParse } from "./utils";
export const FetchError = ModernError.subclass("FetchError", {
props: {
body: "",
code: 500,
},
});
export const FetchParseError = ModernError.subclass("FetchParseError");
type FetchConfig<T> = {
schema: z.ZodType<T>;
onSuccess?: (data: z.infer<z.ZodType<T>>) => void | Promise<void>;
onError?: (
err: InstanceType<typeof FetchError> | InstanceType<typeof FetchParseError>,
) => void | Promise<void>;
onFinished?: () => void | Promise<void>;
};
export const useFetchFn = () => {
const { appBridgeState } = useAppBridge();
const { saleorApiUrl, token } = appBridgeState ?? {};
const customFetch = useCallback(
(info: RequestInfo | URL, init?: RequestInit | undefined) => {
return fetch(info, {
...init,
body: JSON.stringify(init?.body),
headers: {
...init?.headers,
"content-type": "application/json",
"saleor-api-url": saleorApiUrl ?? "",
"authorization-bearer": token ?? "",
},
});
},
[saleorApiUrl, token],
);
return {
fetch: customFetch,
isReady: saleorApiUrl && token,
};
};
async function handleResponse<T>(res: Response, config: FetchConfig<T> | undefined): Promise<void> {
if (!res.ok) {
void config?.onError?.(
new FetchError(res.statusText, {
props: {
body: await res.text(),
code: res.status,
},
}),
);
void config?.onFinished?.();
return;
}
if (config) {
try {
const json = tryJsonParse(await res.text());
const data = config.schema.parse(json);
void config?.onSuccess?.(data);
} catch (err) {
void config?.onError?.(FetchParseError.normalize(err));
}
}
void config?.onFinished?.();
}
/** Fetch function, can be replaced to any fetching library, e.g. React Query, useSWR */
export const useFetch = <T>(endpoint: string, config?: FetchConfig<T>) => {
const { fetch, isReady } = useFetchFn();
const configRef = useRef(config);
// We don't want changes in config to trigger re-fetch
useEffect(() => {
configRef.current = config;
}, [config]);
useEffect(() => {
if (!isReady) {
return;
}
void (async () => {
const res = await fetch(endpoint);
await handleResponse(res, configRef.current);
})();
}, [endpoint, fetch, isReady]);
};
export const usePost = <T>(endpoint: string, config?: FetchConfig<T>) => {
const { fetch, isReady } = useFetchFn();
const submit = useCallback(
async (data: JSONValue, options?: RequestInit | undefined) => {
if (!isReady) {
return;
}
const res = await fetch(endpoint, {
method: "POST",
body: JSON.stringify(data),
...options,
});
await handleResponse(res, config);
},
[config, endpoint, fetch, isReady],
);
return submit;
};

98
src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import { tryIgnore, tryJsonParse, toStringOrEmpty, unpackPromise, unpackThrowable } from "./utils";
import { BaseError } from "@/errors";
describe("api-route-utils", () => {
describe("tryIgnore", () => {
it("should run the function", () => {
const fn = vi.fn();
expect(() => tryIgnore(fn)).not.toThrow();
expect(fn).toHaveBeenCalledOnce();
});
it("should ignore errors", () => {
expect(() =>
tryIgnore(() => {
throw new Error("Error!");
}),
).not.toThrow();
});
});
describe("tryJsonParse", () => {
it("should ignore empty input", () => {
expect(tryJsonParse("")).toBeUndefined();
expect(tryJsonParse(undefined)).toBeUndefined();
});
it("should try parsing to JSON", () => {
expect(tryJsonParse('{"a": 123, "b": {"c": "aaa"}}')).toEqual({ a: 123, b: { c: "aaa" } });
});
it("should return original input in case of error", () => {
expect(tryJsonParse('{"a": 123, "b" {"c": "aaa"}}')).toBe('{"a": 123, "b" {"c": "aaa"}}');
});
});
describe("toStringOrEmpty", () => {
it("should return value if it's a string", () => {
expect(toStringOrEmpty("")).toBe("");
expect(toStringOrEmpty("some string")).toBe("some string");
});
it.each([0, 1, 1n, {}, [], undefined, null, false, true])(
"should return empty string if value is not a string: %p",
(value) => {
expect(toStringOrEmpty(value)).toBe("");
},
);
});
describe("unpackPromise", () => {
it("returns value if promise resolves", async () => {
const [error, value] = await unpackPromise(Promise.resolve("some value"));
expect(error).toBeNull();
expect(value).toBe("some value");
});
it("returns error if promise rejects", async () => {
const [error, value] = await unpackPromise(Promise.reject("some error"));
expect(error).toMatchInlineSnapshot("[UnknownError: some error]");
expect(value).toBeNull();
});
it("preserves error if it's BaseError or descendants", async () => {
const SomeError = BaseError.subclass("SomeError");
const [error, value] = await unpackPromise(Promise.reject(new SomeError("some error")));
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
expect(value).toBeNull();
});
});
describe("unpackThrowable", () => {
it("returns value if promise resolves", async () => {
const [error, value] = unpackThrowable(() => {
return "some value";
});
expect(error).toBeNull();
expect(value).toBe("some value");
});
it("returns error if promise rejects", async () => {
const [error, value] = unpackThrowable(() => {
throw new Error("some error");
});
expect(error).toMatchInlineSnapshot("[Error: some error]");
expect(value).toBeNull();
});
it("preserves error if it's BaseError or descendants", async () => {
const SomeError = BaseError.subclass("SomeError");
const [error, value] = unpackThrowable(() => {
throw new SomeError("some error");
});
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
expect(value).toBeNull();
});
});
});

62
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { JSONValue } from "../types";
import { BaseError, UnknownError } from "@/errors";
export const tryJsonParse = (text: string | undefined) => {
if (!text) {
return undefined;
}
try {
return JSON.parse(text) as JSONValue;
} catch (e) {
return text;
}
};
export const tryIgnore = (fn: () => void) => {
try {
fn();
} catch {
// noop
}
};
export const toStringOrEmpty = (value: unknown) => {
if (typeof value === "string") return value;
return "";
};
type PromiseToTupleResult<T> = [Error, null] | [null, Awaited<T>];
export const unpackPromise = async <T extends Promise<unknown>>(
promise: T,
): Promise<PromiseToTupleResult<T>> => {
try {
const result = await promise;
return [null, result];
} catch (maybeError) {
if (maybeError instanceof Error) {
return [maybeError, null];
}
return [BaseError.normalize(maybeError, UnknownError), null];
}
};
type ThrowableToTupleResult<T> = [Error, null] | [null, T];
export const unpackThrowable = <T>(throwable: () => T): ThrowableToTupleResult<T> => {
try {
const result = throwable();
return [null, result];
} catch (maybeError) {
if (maybeError instanceof Error) {
return [maybeError, null];
}
return [BaseError.normalize(maybeError, UnknownError), null];
}
};
export const isNotNullish = <T>(val: T | null | undefined): val is T =>
val !== undefined && val !== null;
export const isObject = (val: unknown): val is Record<string, unknown> =>
typeof val === "object" && val !== null && !Array.isArray(val);
export const __do = <T>(fn: () => T): T => fn();

2
src/load-env.ts Normal file
View File

@@ -0,0 +1,2 @@
import nextEnv from "@next/env";
nextEnv.loadEnvConfig(".");

View File

@@ -0,0 +1,10 @@
query Migration_01_FetchWebhookIds {
app {
webhooks {
id
syncEvents {
eventType
}
}
}
}

View File

@@ -0,0 +1,12 @@
mutation Migration_01_UpdateWebhook($webhookId: ID!, $newQuery: String!) {
webhookUpdate(id: $webhookId, input: { query: $newQuery }) {
webhook {
id
}
errors {
code
field
message
}
}
}

View File

@@ -0,0 +1,42 @@
import { type AuthData } from "@saleor/app-sdk/APL";
import { createServerClient } from "@/lib/create-graphq-client";
import {
Migration_01_FetchWebhookIdsDocument,
Migration_01_UpdateWebhookDocument,
UntypedTransactionInitializeSessionDocument,
WebhookEventTypeSyncEnum,
} from "generated/graphql";
import { gqlAstToString } from "@/lib/gql-ast-to-string";
import { type PaymentAppConfigurator } from "@/modules/payment-app-configuration/payment-app-configuration";
export const requiredSaleorVersion = "3.13";
export async function migrate(authData: AuthData, _configurator: PaymentAppConfigurator) {
const client = createServerClient(authData.saleorApiUrl, authData.token);
const { data: fetchWebhookData } = await client
.query(Migration_01_FetchWebhookIdsDocument, {})
.toPromise();
const webhook = fetchWebhookData?.app?.webhooks?.find((webhook) =>
webhook.syncEvents.find(
(syncEvent) => syncEvent.eventType === WebhookEventTypeSyncEnum.TransactionInitializeSession,
),
);
if (!webhook) {
throw new Error("No webhook to update");
}
const webhookId = webhook.id;
const { data: updateWebhookData, error } = await client
.mutation(Migration_01_UpdateWebhookDocument, {
newQuery: gqlAstToString(UntypedTransactionInitializeSessionDocument),
webhookId,
})
.toPromise();
if (error || !updateWebhookData?.webhookUpdate?.webhook?.id) {
throw new Error("Error while updating webhook");
}
}

View File

@@ -0,0 +1,159 @@
import { type MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
PrivateMetadataAppConfigurator,
PublicMetadataAppConfiguration,
serializeSettingsToMetadata,
} from "./app-configuration";
import {
createWebhookPrivateSettingsManager,
createWebhookPublicSettingsManager,
} from "./metadata-manager";
import { obfuscateValue, filterConfigValues, OBFUSCATION_DOTS } from "./utils";
import { testEnv } from "@/__tests__/test-env.mjs";
describe("obfuscateValue", () => {
it("obfuscates fully short values", () => {
expect(obfuscateValue("123")).toEqual(OBFUSCATION_DOTS);
expect(obfuscateValue("")).toEqual(OBFUSCATION_DOTS);
expect(obfuscateValue("1234")).toEqual(OBFUSCATION_DOTS);
});
it("leaves 4 charts of obfuscated value visible", () => {
expect(obfuscateValue("12345")).toBe(`${OBFUSCATION_DOTS}5`);
expect(obfuscateValue("123456")).toBe(`${OBFUSCATION_DOTS}56`);
expect(obfuscateValue("1234567")).toBe(`${OBFUSCATION_DOTS}567`);
expect(obfuscateValue("12345678")).toBe(`${OBFUSCATION_DOTS}5678`);
expect(obfuscateValue("123456789")).toBe(`${OBFUSCATION_DOTS}6789`);
});
});
describe("filterConfigValues", () => {
it("filters out null and undefined values", () => {
expect(filterConfigValues({ a: 1, b: null, c: undefined })).toEqual({ a: 1 });
});
});
describe("PublicMetadataAppConfigurator", () => {
const onUpdate = vi.fn((update: MetadataEntry[]) => Promise.resolve(update));
beforeEach(() => {
onUpdate.mockClear();
});
const KEY = "some-metadata";
const getMetadata = (value?: unknown) => {
return serializeSettingsToMetadata({
key: KEY,
domain: testEnv.TEST_SALEOR_API_URL,
value: value ? JSON.stringify(value) : "",
});
};
const defaultMetadata = { a: "a" };
const managerEmpty = new PublicMetadataAppConfiguration(
createWebhookPublicSettingsManager([], onUpdate),
testEnv.TEST_SALEOR_API_URL,
KEY,
);
// make tests easier
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let manager: PublicMetadataAppConfiguration<any>;
beforeEach(() => {
manager = new PublicMetadataAppConfiguration(
createWebhookPublicSettingsManager([getMetadata(defaultMetadata)], onUpdate),
testEnv.TEST_SALEOR_API_URL,
"some-metadata",
);
});
it("gets metadata from metadataManager and parses it", async () => {
await expect(manager.getConfig()).resolves.toEqual(defaultMetadata);
await expect(managerEmpty.getConfig()).resolves.toBeUndefined();
});
it("gets metadata in raw config form", async () => {
await expect(manager.getRawConfig()).resolves.toEqual([getMetadata(defaultMetadata)]);
await expect(managerEmpty.getRawConfig()).resolves.toEqual([getMetadata()]);
});
describe("setConfig", () => {
it("skips saving metadata if there is nothing new", async () => {
await manager.setConfig({});
expect(onUpdate).not.toHaveBeenCalled();
});
it("replaces metadata if param is passed", async () => {
await manager.setConfig({}, true);
expect(onUpdate).toHaveBeenCalledWith([
serializeSettingsToMetadata({
key: "some-metadata",
value: JSON.stringify({}),
domain: testEnv.TEST_SALEOR_API_URL,
}),
]);
await manager.setConfig({ b: "b" }, true);
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
});
it("saves only settings that have values", async () => {
await managerEmpty.setConfig({ a: null, b: "b", c: undefined });
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
});
it("merges new settings with existing ones", async () => {
await manager.setConfig({ b: "b" });
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ a: "a", b: "b" })]);
});
});
it("clears metadata", async () => {
await manager.clearConfig();
expect(onUpdate).toHaveBeenCalledWith([
serializeSettingsToMetadata({
key: "some-metadata",
value: "",
domain: testEnv.TEST_SALEOR_API_URL,
}),
]);
});
});
describe("PrivateMetadataAppConfigurator", () => {
const metadataConfigurator = new PrivateMetadataAppConfigurator(
createWebhookPublicSettingsManager([
serializeSettingsToMetadata({
key: "some-metadata",
value: JSON.stringify({ a: "123456", b: "1234" }),
domain: testEnv.TEST_SALEOR_API_URL,
}),
]),
testEnv.TEST_SALEOR_API_URL,
"some-metadata",
);
it("can obfuscate provided config object", () => {
expect(metadataConfigurator.obfuscateConfig({ a: "12345" })).toEqual({
a: `${OBFUSCATION_DOTS}5`,
});
});
it("returns empty config when none is set", async () => {
const emptyMetadataConfigurator = new PrivateMetadataAppConfigurator(
createWebhookPrivateSettingsManager([]),
testEnv.TEST_SALEOR_API_URL,
"some-metadata",
);
await expect(emptyMetadataConfigurator.getConfigObfuscated()).resolves.toBeUndefined();
});
it("can obfuscate its own config", async () => {
await expect(metadataConfigurator.getConfigObfuscated()).resolves.toEqual({
a: `${OBFUSCATION_DOTS}56`,
b: OBFUSCATION_DOTS,
});
});
});

View File

@@ -0,0 +1,126 @@
import {
type SettingsValue,
type MetadataEntry,
type SettingsManager,
} from "@saleor/app-sdk/settings-manager";
import merge from "lodash-es/merge";
import { toStringOrEmpty } from "../../lib/utils";
import { filterConfigValues, obfuscateValue } from "./utils";
import { logger as pinoLogger } from "@/lib/logger";
export interface GenericAppConfigurator<TConfig extends Record<string, unknown>> {
setConfig(config: TConfig): Promise<void>;
getConfig(): Promise<TConfig | undefined>;
}
// Taken from @saleor/app-sdk/src/settings-manager
export const serializeSettingsToMetadata = ({
key,
value,
domain,
}: SettingsValue): MetadataEntry => {
// domain specific metadata use convention key__domain, e.g. `secret_key__example.com`
if (!domain) {
return { key, value };
}
return {
key: [key, domain].join("__"),
value,
};
};
export abstract class MetadataConfigurator<TConfig extends Record<string, unknown>>
implements GenericAppConfigurator<TConfig>
{
constructor(
protected metadataManager: SettingsManager,
protected saleorApiUrl: string,
protected metadataKey: string,
) {}
async getConfig(): Promise<TConfig | undefined> {
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
if (!data) {
return undefined;
}
try {
return JSON.parse(data) as TConfig;
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
}
async getRawConfig(
prepareValue: (val: string) => string = (data) => data,
): Promise<MetadataEntry[]> {
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
return [
// metadataManager strips out domain from key, we need to add it back
serializeSettingsToMetadata({
key: this.metadataKey,
value: prepareValue(data ?? ""),
domain: this.saleorApiUrl,
}),
];
}
async setConfig(newConfig: Partial<TConfig>, replace = false) {
const logger = pinoLogger.child({
saleorApiUrl: this.saleorApiUrl,
metadataKey: this.metadataKey,
});
const filteredNewConfig = filterConfigValues(newConfig);
if (Object.keys(filteredNewConfig).length === 0 && !replace) {
logger.debug("No config to safe in metadata");
return;
}
const existingConfig = replace ? {} : await this.getConfig();
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(merge(existingConfig, filteredNewConfig)),
domain: this.saleorApiUrl,
});
}
async clearConfig() {
return this.metadataManager.set({
key: this.metadataKey,
value: "",
domain: this.saleorApiUrl,
});
}
}
export class PublicMetadataAppConfiguration<
TConfig extends Record<string, unknown>,
> extends MetadataConfigurator<TConfig> {}
export class PrivateMetadataAppConfigurator<
TConfig extends Record<string, unknown>,
> extends MetadataConfigurator<TConfig> {
constructor(metadataManager: SettingsManager, saleorApiUrl: string, metadataKey: string) {
super(metadataManager, saleorApiUrl, metadataKey);
}
obfuscateConfig(config: TConfig): TConfig {
const entries = Object.entries(config).map(([key, value]) => [
key,
obfuscateValue(toStringOrEmpty(value)),
]);
return Object.fromEntries(entries) as TConfig;
}
async getConfigObfuscated(): Promise<TConfig | undefined> {
const config = await this.getConfig();
if (!config) {
return undefined;
}
return this.obfuscateConfig(config);
}
}

View File

@@ -0,0 +1,142 @@
import {
EncryptedMetadataManager,
type MetadataEntry,
MetadataManager,
type MutateMetadataCallback,
} from "@saleor/app-sdk/settings-manager";
import { type Client } from "urql";
import {
FetchAppDetailsDocument,
type FetchAppDetailsQuery,
UpdateAppMetadataDocument,
UpdatePublicMetadataDocument,
} from "../../../generated/graphql";
import { env } from "@/lib/env.mjs";
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
const combinedMetadata = [...(data?.app?.metadata || []), ...(data?.app?.privateMetadata || [])];
return combinedMetadata.map((md) => ({ key: md.key, value: md.value }));
}
async function getAppId(client: Client) {
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid.",
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
return appId;
}
export async function mutatePrivateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const appId = await getAppId(client);
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export async function mutatePublicMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const appId = await getAppId(client);
const { error: mutationError, data: mutationData } = await client
.mutation(UpdatePublicMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updateMetadata?.item?.metadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
// branded types are used to prevent using wrong manager for wrong metadata
type Brand<K, T> = K & { __brand: T };
export type BrandedEncryptedMetadataManager = Brand<
EncryptedMetadataManager,
"EncryptedMetadataManager"
>;
export type BrandedMetadataManager = Brand<MetadataManager, "MetadataManager">;
export const createPrivateSettingsManager = (client: Client) => {
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
// We recommend it for production, because all values are encrypted.
// If your use case require plain text values, you can use MetadataManager.
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: env.SECRET_KEY,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutatePrivateMetadata(client, metadata),
}) as BrandedEncryptedMetadataManager;
};
export const createPublicSettingsManager = (client: Client) => {
return new MetadataManager({
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutatePublicMetadata(client, metadata),
}) as BrandedMetadataManager;
};
export const createWebhookPrivateSettingsManager = (
data: MetadataEntry[],
onUpdate?: MutateMetadataCallback,
) => {
return new EncryptedMetadataManager({
encryptionKey: env.SECRET_KEY,
fetchMetadata: () => Promise.resolve(data),
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
}) as BrandedEncryptedMetadataManager;
};
export const createWebhookPublicSettingsManager = (
data: MetadataEntry[],
onUpdate?: MutateMetadataCallback,
) => {
return new MetadataManager({
fetchMetadata: () => Promise.resolve(data),
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
}) as BrandedMetadataManager;
};

View File

@@ -0,0 +1,38 @@
import { isNotNullish, toStringOrEmpty } from "../../lib/utils";
export const OBFUSCATION_DOTS = "••••";
export const obfuscateValue = (value: string) => {
const unbofuscatedLength = Math.min(4, value.length - 4);
if (unbofuscatedLength <= 0) {
return OBFUSCATION_DOTS;
}
const visibleValue = value.slice(-unbofuscatedLength);
return `${OBFUSCATION_DOTS}${visibleValue}`;
};
export const deobfuscateValues = (values: Record<string, unknown>) => {
const entries = Object.entries(values).map(
([key, value]) =>
[key, toStringOrEmpty(value).includes(OBFUSCATION_DOTS) ? null : value] as [string, unknown],
);
return Object.fromEntries(entries);
};
export const filterConfigValues = <T extends Record<string, unknown>>(values: T) => {
const entries = Object.entries(values).filter(
([_, value]) => value !== null && value !== undefined,
);
return Object.fromEntries(entries);
};
export const obfuscateConfig = <T extends {}>(config: T): T => {
const entries = Object.entries(config).map(([key, value]) => [
key,
isNotNullish(value) ? obfuscateValue(toStringOrEmpty(value)) : value,
]);
return Object.fromEntries(entries) as T;
};

Some files were not shown because too many files have changed in this diff Show More