wdc
15
.changeset/config.json
Normal 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
@@ -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.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
|
||||||
|
APP_DEBUG=info
|
||||||
|
SECRET_KEY=aaaaaaaa
|
||||||
14
.env.test
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
generated/
|
||||||
|
src/schemas/**/*.mjs
|
||||||
|
src/schemas/**/*.mts
|
||||||
150
.eslintrc
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
*.har linguist-generated=true
|
||||||
48
.graphqlrc.yml
Normal 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
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm exec lint-staged
|
||||||
13
.lintstagedrc.js
Normal 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",
|
||||||
|
};
|
||||||
11
.prettierignore
Normal 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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
8
.vscode/extensions.json
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
* @saleor/apps-guild
|
||||||
|
/.github/ @saleor/SRE
|
||||||
|
* @saleor/Shopex-JS
|
||||||
50
Dockerfile
Normal 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" ]
|
||||||
5
SECURITY.md
Normal 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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
0
graphql/fragments/.gitkeep
Normal file
4
graphql/fragments/Money.graphql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fragment Money on Money {
|
||||||
|
currency
|
||||||
|
amount
|
||||||
|
}
|
||||||
110
graphql/fragments/OrderOrCheckoutLines.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
graphql/fragments/OrderOrCheckoutSourceObject.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fragment PaymentGatewayInitializeSessionAddress on Address {
|
||||||
|
country {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
graphql/fragments/PaymentGatewayRecipient.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fragment PaymentGatewayRecipient on App {
|
||||||
|
id
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
fragment TransactionCancelationRequestedEvent on TransactionCancelationRequested {
|
||||||
|
__typename
|
||||||
|
recipient {
|
||||||
|
...PaymentGatewayRecipient
|
||||||
|
}
|
||||||
|
action {
|
||||||
|
actionType
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
transaction {
|
||||||
|
id
|
||||||
|
pspReference
|
||||||
|
sourceObject: order {
|
||||||
|
channel {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
graphql/fragments/TransactionChargeRequestedEvent.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
fragment TransactionInitializeSessionAddress on Address {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
phone
|
||||||
|
city
|
||||||
|
streetAddress1
|
||||||
|
streetAddress2
|
||||||
|
postalCode
|
||||||
|
countryArea
|
||||||
|
companyName
|
||||||
|
country {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
26
graphql/fragments/TransactionInitializeSessionEvent.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
54
graphql/fragments/TransactionProcessSessionEvent.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
graphql/fragments/TransactionRefundRequestedEvent.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
graphql/mutations/.gitkeep
Normal file
28
graphql/mutations/TransactionEventReport.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
graphql/mutations/UpdateAppMetadata.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||||
|
updatePrivateMetadata(id: $id, input: $input) {
|
||||||
|
item {
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
graphql/mutations/UpdatePublicMetadata.graphql
Normal 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
16
graphql/queries/FetchAppDetails.graphql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
query FetchAppDetails {
|
||||||
|
shop {
|
||||||
|
schemaVersion
|
||||||
|
}
|
||||||
|
app {
|
||||||
|
id
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
graphql/queries/FetchChannels.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
query FetchChannels {
|
||||||
|
channels {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
28869
graphql/schema.graphql
Normal file
0
graphql/subscriptions/.gitkeep
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
subscription PaymentGatewayInitializeSession {
|
||||||
|
event {
|
||||||
|
...PaymentGatewayInitializeSessionEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
subscription TransactionCancelationRequested {
|
||||||
|
event {
|
||||||
|
...TransactionCancelationRequestedEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
5
graphql/subscriptions/TransactionChargeRequested.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
subscription TransactionChargeRequested {
|
||||||
|
event {
|
||||||
|
...TransactionChargeRequestedEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
subscription TransactionInitializeSession {
|
||||||
|
event {
|
||||||
|
...TransactionInitializeSessionEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
5
graphql/subscriptions/TransactionProcessSession.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
subscription TransactionProcessSession {
|
||||||
|
event {
|
||||||
|
...TransactionProcessSessionEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
5
graphql/subscriptions/TransactionRefundRequested.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
subscription TransactionRefundRequested {
|
||||||
|
event {
|
||||||
|
...TransactionRefundRequestedEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
5
next-env.d.ts
vendored
Normal 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
@@ -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
@@ -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
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 453 B |
BIN
public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 733 B |
BIN
public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 978 B |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
18
sentry.client.config.js
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
defaults.url=https://sentry.io/
|
||||||
|
defaults.org=saleor
|
||||||
|
defaults.project=
|
||||||
|
cli.executable=
|
||||||
18
sentry.server.config.js
Normal 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
|
||||||
|
});
|
||||||
20
src/__tests__/apiTestsUtils.ts
Normal 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) }),
|
||||||
|
});
|
||||||
|
};
|
||||||
26
src/__tests__/pages/index.test.tsx
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
51
src/__tests__/test-env.mjs
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app-bridge-instance.ts
Normal 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;
|
||||||
294
src/backend-lib/api-route-utils.test.ts
Normal 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",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/backend-lib/api-route-utils.ts
Normal 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
@@ -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
@@ -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
@@ -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);
|
||||||
52
src/lib/create-graphq-client.ts
Normal 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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
8
src/lib/gql-ast-to-string.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
import nextEnv from "@next/env";
|
||||||
|
nextEnv.loadEnvConfig(".");
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
query Migration_01_FetchWebhookIds {
|
||||||
|
app {
|
||||||
|
webhooks {
|
||||||
|
id
|
||||||
|
syncEvents {
|
||||||
|
eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/migrations/1-add-issuing-pricinpal/UpdateWebhook.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/migrations/1-add-issuing-pricinpal/index.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/modules/app-configuration/app-configuration.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
src/modules/app-configuration/app-configuration.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/modules/app-configuration/metadata-manager.ts
Normal 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;
|
||||||
|
};
|
||||||
38
src/modules/app-configuration/utils.ts
Normal 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;
|
||||||
|
};
|
||||||
43
src/modules/jwt/check-token-expiration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { SignJWT } from "jose";
|
||||||
|
import { checkTokenExpiration } from "./check-token-expiration";
|
||||||
|
|
||||||
|
describe("checkTokenExpiration", () => {
|
||||||
|
const secretKey = new TextEncoder().encode("test");
|
||||||
|
|
||||||
|
it("returns false if token is undefined", () => {
|
||||||
|
expect(checkTokenExpiration(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if token doesn't have expire date", async () => {
|
||||||
|
// create JWT token without exp claim
|
||||||
|
const jwt = await new SignJWT({ id: "12345" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.sign(new Uint8Array(secretKey));
|
||||||
|
expect(checkTokenExpiration(jwt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if token is not expired", async () => {
|
||||||
|
const jwt = await new SignJWT({ id: "12345" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("5s")
|
||||||
|
.sign(secretKey);
|
||||||
|
expect(checkTokenExpiration(jwt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true if token is expired", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2000, 1, 1, 13, 1, 1));
|
||||||
|
|
||||||
|
const jwt = await new SignJWT({ id: "12345" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("1s")
|
||||||
|
.sign(secretKey);
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date(2000, 1, 1, 13, 1, 11)); // 10 seconds later
|
||||||
|
|
||||||
|
expect(checkTokenExpiration(jwt)).toBe(true);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/modules/jwt/check-token-expiration.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { decodeJwt } from "jose";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/** Checks if JWT token expired.
|
||||||
|
* Returns false if token is still valid (note: there could be other issues)
|
||||||
|
* or true if it expired */
|
||||||
|
export const checkTokenExpiration = (token: string | undefined): boolean => {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
if (claims.exp) {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const expireDate = claims.exp * 1000;
|
||||||
|
|
||||||
|
logger.trace({ claimsExp: claims.exp, now, expireDate }, "JWT token expiration time");
|
||||||
|
return now >= expireDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
23
src/modules/jwt/check-token-offline.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { type Permission } from "@saleor/app-sdk/types";
|
||||||
|
import { decodeJwt } from "jose";
|
||||||
|
|
||||||
|
const isPermissionsArray = (permissions: unknown): permissions is Permission[] => {
|
||||||
|
return Array.isArray(permissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkTokenPermissions = (
|
||||||
|
token: string | undefined,
|
||||||
|
permissions: Permission[],
|
||||||
|
): boolean => {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
if (isPermissionsArray(claims.user_permissions)) {
|
||||||
|
const userPermissions = new Set(claims.user_permissions);
|
||||||
|
return permissions.every((requiredPermission) => userPermissions.has(requiredPermission));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||