This commit is contained in:
jackbeeby
2024-12-05 18:20:27 +11:00
parent b8de0556ec
commit dc5eea1ad0
288 changed files with 101937 additions and 0 deletions

15
.changeset/config.json Normal file
View File

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

274
.dependency-cruiser.js Normal file
View File

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

3
.env.example Normal file
View File

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

14
.env.test Normal file
View File

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

3
.eslintignore Normal file
View File

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

150
.eslintrc Normal file
View File

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

1
.gitattributes vendored Normal file
View File

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

48
.graphqlrc.yml Normal file
View File

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

4
.husky/pre-commit Executable file
View File

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

13
.lintstagedrc.js Normal file
View File

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

2
.npmrc Normal file
View File

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

11
.prettierignore Normal file
View File

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

5
.prettierrc Normal file
View File

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

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

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

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

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

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

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

40
CHANGELOG.md Normal file
View File

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

3
CODEOWNERS Normal file
View File

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

50
Dockerfile Normal file
View File

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

5
SECURITY.md Normal file
View File

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

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

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

39
global.d.ts vendored Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

0
graphql/queries/.gitkeep Normal file
View File

View File

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

View File

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

28869
graphql/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

31
next.config.mjs Normal file
View File

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

152
package.json Normal file
View File

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

12008
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

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

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

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

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

4
sentry.properties Normal file
View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

12
src/deploy.ts Normal file
View File

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

79
src/errors.ts Normal file
View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
});
});

View 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;
};

View 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;
};

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