├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── examples ├── bun-plain │ ├── .npmrc │ ├── bun.lockb │ ├── index.ts │ └── package.json ├── cloudflare-hono │ ├── .gitignore │ ├── .npmrc │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml ├── deno-hono │ ├── Test │ │ └── manifest.xml │ ├── deno.json │ ├── deno.lock │ └── mod.ts └── node-hono │ ├── .npmrc │ ├── Test │ └── manifest.xml │ ├── index.ts │ ├── package-lock.json │ └── package.json └── src ├── LICENSE ├── README.md ├── app.ts ├── context-resolver.ts ├── express.ts ├── framework ├── express.ts └── hono.ts ├── helper └── app-actions.ts ├── http-client.ts ├── jsr.json ├── mod.ts ├── registration.ts ├── repository.ts ├── service ├── cloudflare.ts └── deno.ts ├── signer.ts └── tsconfig.json /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: JSR Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | name: Publish Package 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Update version in JSR 19 | working-directory: src 20 | run: | 21 | jq '.version = "${{ github.event.release.tag_name }}"' jsr.json > jsr.json.tmp 22 | mv jsr.json.tmp jsr.json 23 | 24 | - name: Publish package 25 | working-directory: src 26 | run: npx jsr publish --allow-dirty 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | node_modules 3 | /dist 4 | npm 5 | /examples/*/bun.lockdb 6 | /examples/*/package-lock.json 7 | /examples/*/.wrangler 8 | 9 | # Devenv 10 | .direnv 11 | .devenv* 12 | devenv.local.nix 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "files.exclude": { 5 | ".devenv": true, 6 | ".direnv": true 7 | } 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Friends of Shopware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | Use https://github.com/shopware/app-sdk-js instead 4 | -------------------------------------------------------------------------------- /examples/bun-plain/.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /examples/bun-plain/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/app-server-sdk-js/87bc1dfe48eba408f73e898c1a7b39b6dcb12d51/examples/bun-plain/bun.lockb -------------------------------------------------------------------------------- /examples/bun-plain/index.ts: -------------------------------------------------------------------------------- 1 | import { AppServer, InMemoryShopRepository } from '@friendsofshopware/app-server' 2 | import { createNotificationResponse } from '@friendsofshopware/app-server/helper/app-actions' 3 | 4 | const app = new AppServer({ 5 | appName: 'MyApp', 6 | appSecret: 'my-secret', 7 | authorizeCallbackUrl: 'http://localhost:3000/authorize/callback', 8 | }, new InMemoryShopRepository()); 9 | 10 | const server = Bun.serve({ 11 | port: 3000, 12 | async fetch(request) { 13 | const { pathname } = new URL(request.url); 14 | if (pathname === '/authorize') { 15 | return app.registration.authorize(request); 16 | } else if (pathname === '/authorize/callback') { 17 | return app.registration.authorizeCallback(request); 18 | } else if (pathname === '/app/product') { 19 | const context = await app.contextResolver.fromSource(request); 20 | 21 | // do something with payload, and http client 22 | 23 | const notification = createNotificationResponse('success', 'Product created'); 24 | 25 | // sign the response, with the shop secret 26 | await app.signer.signResponse(notification, context.shop.getShopSecret()); 27 | 28 | return resp; 29 | } 30 | 31 | return new Response('Not found', { status: 404 }); 32 | }, 33 | }); 34 | 35 | console.log(`Listening on localhost:${server.port}`); 36 | -------------------------------------------------------------------------------- /examples/bun-plain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bun-plain", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "description": "", 11 | "devDependencies": { 12 | "@types/bun": "^1.1.5" 13 | }, 14 | "dependencies": { 15 | "@friendsofshopware/app-server": "npm:@jsr/friendsofshopware__app-server@^0.0.55" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | transpiled 4 | worker.js* 5 | /.idea/ 6 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker-typescript-template", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "Cloudflare worker TypeScript template", 6 | "main": "worker.js", 7 | "scripts": { 8 | "dev": "wrangler dev --local" 9 | }, 10 | "author": "author", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@friendsofshopware/app-server": "npm:@jsr/friendsofshopware__app-server@^0.0.55", 14 | "hono": "~4.4.7" 15 | }, 16 | "devDependencies": { 17 | "@cloudflare/workers-types": "^4.20240620.0", 18 | "esbuild": "0.21.5", 19 | "typescript": "^5.5", 20 | "wrangler": "^3.61.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/src/index.ts: -------------------------------------------------------------------------------- 1 | import { configureAppServer } from "@friendsofshopware/app-server/framework/hono"; 2 | import { CloudflareShopRepository } from "@friendsofshopware/app-server/service/cloudflare-workers"; 3 | import { Hono } from "hono"; 4 | import type { 5 | AppServer, 6 | Context, 7 | ShopInterface, 8 | } from "@friendsofshopware/app-server"; 9 | import { createNotificationResponse } from "@friendsofshopware/app-server/helper/app-actions"; 10 | 11 | const app = new Hono(); 12 | 13 | declare module "hono" { 14 | interface ContextVariableMap { 15 | app: AppServer; 16 | shop: ShopInterface; 17 | context: Context; 18 | } 19 | } 20 | 21 | app.post('/app/action-button/product', async ctx => { 22 | console.log(`Got request from Shop ${ctx.get('shop').getShopId()}`) 23 | return createNotificationResponse('success', 'YEAA'); 24 | }); 25 | 26 | configureAppServer(app, { 27 | appName: 'SwagTest', 28 | appSecret: 'SwagTest', 29 | shopRepository: (ctx) => { 30 | return new CloudflareShopRepository(ctx.env.shopStorage); 31 | } 32 | }) 33 | 34 | export default app; 35 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "ESNext", 5 | "target": "esnext", 6 | "lib": ["esnext", "webworker", "dom"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "NodeNext", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": [ 14 | "@cloudflare/workers-types" 15 | ] 16 | }, 17 | "include": ["src"], 18 | "exclude": ["node_modules", "dist", "test"] 19 | } 20 | -------------------------------------------------------------------------------- /examples/cloudflare-hono/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "shopware-app-server" 2 | main = "src/index.ts" 3 | workers_dev = true 4 | compatibility_date = "2023-05-07" 5 | -------------------------------------------------------------------------------- /examples/deno-hono/Test/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | Test 8 | Test 9 | Shyim 10 | Shyim 11 | 0.0.12 12 | icon.png 13 | MIT 14 | 15 | 16 | https://sw-app-test.deno.dev/app/register 17 | Test 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/deno-hono/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@friendsofshopware/app-server": "jsr:@friendsofshopware/app-server@^0.0.55", 4 | "hono": "jsr:@hono/hono@^4.4.7" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/deno-hono/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@friendsofshopware/app-server@^0.0.55": "jsr:@friendsofshopware/app-server@0.0.55", 6 | "jsr:@hono/hono@^4.4.7": "jsr:@hono/hono@4.4.7" 7 | }, 8 | "jsr": { 9 | "@friendsofshopware/app-server@0.0.55": { 10 | "integrity": "4b6ecf91038a157940443ccb4fb6acb030cdc0c53d490e4008e0183fd74b2a69" 11 | }, 12 | "@hono/hono@4.4.7": { 13 | "integrity": "069f612a089362e59786ea925d19fa834096fe92bf4f4b8dd572c508a3a708aa" 14 | } 15 | } 16 | }, 17 | "remote": {}, 18 | "workspace": { 19 | "dependencies": [ 20 | "jsr:@friendsofshopware/app-server@^0.0.55", 21 | "jsr:@hono/hono@^4.4.7" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/deno-hono/mod.ts: -------------------------------------------------------------------------------- 1 | import { DenoKVRepository } from "@friendsofshopware/app-server/service/deno"; 2 | import { configureAppServer } from "@friendsofshopware/app-server/framework/hono"; 3 | import { Hono } from "hono"; 4 | import type { 5 | AppServer, 6 | Context, 7 | ShopInterface, 8 | } from "@friendsofshopware/app-server"; 9 | import { createNotificationResponse } from "@friendsofshopware/app-server/helper/app-actions"; 10 | 11 | const app = new Hono(); 12 | 13 | declare module "hono" { 14 | interface ContextVariableMap { 15 | app: AppServer; 16 | shop: ShopInterface; 17 | context: Context; 18 | } 19 | } 20 | 21 | app.post('/app/action-button/product', async ctx => { 22 | console.log(`Got request from Shop ${ctx.get('shop').getShopId()}`) 23 | return createNotificationResponse('success', 'YEAA'); 24 | }); 25 | 26 | configureAppServer(app, { 27 | appName: 'SwagTest', 28 | appSecret: 'SwagTest', 29 | shopRepository: new DenoKVRepository(), 30 | }) 31 | 32 | export default app; 33 | -------------------------------------------------------------------------------- /examples/node-hono/.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /examples/node-hono/Test/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | Test 8 | Test 9 | Shyim 10 | Shyim 11 | 0.0.12 12 | icon.png 13 | MIT 14 | 15 | 16 | http://localhost:3000/app/register 17 | Test 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/node-hono/index.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryShopRepository } from "@friendsofshopware/app-server"; 2 | import { configureAppServer } from "@friendsofshopware/app-server/framework/hono"; 3 | import { Hono } from "hono"; 4 | import type { 5 | AppServer, 6 | Context, 7 | ShopInterface, 8 | } from "@friendsofshopware/app-server"; 9 | import { createNotificationResponse } from "@friendsofshopware/app-server/helper/app-actions"; 10 | 11 | import { serve } from '@hono/node-server'; 12 | 13 | declare module "hono" { 14 | interface ContextVariableMap { 15 | app: AppServer; 16 | shop: ShopInterface; 17 | context: Context; 18 | } 19 | } 20 | 21 | const app = new Hono() 22 | 23 | configureAppServer(app, { 24 | appName: "Test", 25 | appSecret: "Test", 26 | shopRepository: new InMemoryShopRepository(), 27 | }); 28 | 29 | app.post('/app/product', async (ctx) => { 30 | const shop = ctx.get('shop'); 31 | console.log(shop.getShopUrl()); 32 | 33 | const client = ctx.get('context'); 34 | console.log(await client.httpClient.get('/_info/version')); 35 | 36 | return createNotificationResponse('success', 'Product created') 37 | }); 38 | 39 | serve(app, (info) => { 40 | console.log(`Listening on http://localhost:${info.port}`) 41 | }) 42 | -------------------------------------------------------------------------------- /examples/node-hono/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopware-hono", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "shopware-hono", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@friendsofshopware/app-server": "npm:@jsr/friendsofshopware__app-server@^0.0.55", 13 | "@hono/node-server": "^1.11.4", 14 | "hono": "^4.5.8" 15 | }, 16 | "devDependencies": { 17 | "tsx": "^4.15.7", 18 | "typescript": "^5.5.2" 19 | } 20 | }, 21 | "node_modules/@esbuild/aix-ppc64": { 22 | "version": "0.21.5", 23 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 24 | "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 25 | "cpu": [ 26 | "ppc64" 27 | ], 28 | "dev": true, 29 | "license": "MIT", 30 | "optional": true, 31 | "os": [ 32 | "aix" 33 | ], 34 | "engines": { 35 | "node": ">=12" 36 | } 37 | }, 38 | "node_modules/@esbuild/android-arm": { 39 | "version": "0.21.5", 40 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 41 | "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 42 | "cpu": [ 43 | "arm" 44 | ], 45 | "dev": true, 46 | "license": "MIT", 47 | "optional": true, 48 | "os": [ 49 | "android" 50 | ], 51 | "engines": { 52 | "node": ">=12" 53 | } 54 | }, 55 | "node_modules/@esbuild/android-arm64": { 56 | "version": "0.21.5", 57 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 58 | "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 59 | "cpu": [ 60 | "arm64" 61 | ], 62 | "dev": true, 63 | "license": "MIT", 64 | "optional": true, 65 | "os": [ 66 | "android" 67 | ], 68 | "engines": { 69 | "node": ">=12" 70 | } 71 | }, 72 | "node_modules/@esbuild/android-x64": { 73 | "version": "0.21.5", 74 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 75 | "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 76 | "cpu": [ 77 | "x64" 78 | ], 79 | "dev": true, 80 | "license": "MIT", 81 | "optional": true, 82 | "os": [ 83 | "android" 84 | ], 85 | "engines": { 86 | "node": ">=12" 87 | } 88 | }, 89 | "node_modules/@esbuild/darwin-arm64": { 90 | "version": "0.21.5", 91 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", 92 | "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 93 | "cpu": [ 94 | "arm64" 95 | ], 96 | "dev": true, 97 | "license": "MIT", 98 | "optional": true, 99 | "os": [ 100 | "darwin" 101 | ], 102 | "engines": { 103 | "node": ">=12" 104 | } 105 | }, 106 | "node_modules/@esbuild/darwin-x64": { 107 | "version": "0.21.5", 108 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 109 | "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 110 | "cpu": [ 111 | "x64" 112 | ], 113 | "dev": true, 114 | "license": "MIT", 115 | "optional": true, 116 | "os": [ 117 | "darwin" 118 | ], 119 | "engines": { 120 | "node": ">=12" 121 | } 122 | }, 123 | "node_modules/@esbuild/freebsd-arm64": { 124 | "version": "0.21.5", 125 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 126 | "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 127 | "cpu": [ 128 | "arm64" 129 | ], 130 | "dev": true, 131 | "license": "MIT", 132 | "optional": true, 133 | "os": [ 134 | "freebsd" 135 | ], 136 | "engines": { 137 | "node": ">=12" 138 | } 139 | }, 140 | "node_modules/@esbuild/freebsd-x64": { 141 | "version": "0.21.5", 142 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 143 | "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 144 | "cpu": [ 145 | "x64" 146 | ], 147 | "dev": true, 148 | "license": "MIT", 149 | "optional": true, 150 | "os": [ 151 | "freebsd" 152 | ], 153 | "engines": { 154 | "node": ">=12" 155 | } 156 | }, 157 | "node_modules/@esbuild/linux-arm": { 158 | "version": "0.21.5", 159 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 160 | "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 161 | "cpu": [ 162 | "arm" 163 | ], 164 | "dev": true, 165 | "license": "MIT", 166 | "optional": true, 167 | "os": [ 168 | "linux" 169 | ], 170 | "engines": { 171 | "node": ">=12" 172 | } 173 | }, 174 | "node_modules/@esbuild/linux-arm64": { 175 | "version": "0.21.5", 176 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 177 | "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 178 | "cpu": [ 179 | "arm64" 180 | ], 181 | "dev": true, 182 | "license": "MIT", 183 | "optional": true, 184 | "os": [ 185 | "linux" 186 | ], 187 | "engines": { 188 | "node": ">=12" 189 | } 190 | }, 191 | "node_modules/@esbuild/linux-ia32": { 192 | "version": "0.21.5", 193 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 194 | "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 195 | "cpu": [ 196 | "ia32" 197 | ], 198 | "dev": true, 199 | "license": "MIT", 200 | "optional": true, 201 | "os": [ 202 | "linux" 203 | ], 204 | "engines": { 205 | "node": ">=12" 206 | } 207 | }, 208 | "node_modules/@esbuild/linux-loong64": { 209 | "version": "0.21.5", 210 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 211 | "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 212 | "cpu": [ 213 | "loong64" 214 | ], 215 | "dev": true, 216 | "license": "MIT", 217 | "optional": true, 218 | "os": [ 219 | "linux" 220 | ], 221 | "engines": { 222 | "node": ">=12" 223 | } 224 | }, 225 | "node_modules/@esbuild/linux-mips64el": { 226 | "version": "0.21.5", 227 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 228 | "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 229 | "cpu": [ 230 | "mips64el" 231 | ], 232 | "dev": true, 233 | "license": "MIT", 234 | "optional": true, 235 | "os": [ 236 | "linux" 237 | ], 238 | "engines": { 239 | "node": ">=12" 240 | } 241 | }, 242 | "node_modules/@esbuild/linux-ppc64": { 243 | "version": "0.21.5", 244 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 245 | "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 246 | "cpu": [ 247 | "ppc64" 248 | ], 249 | "dev": true, 250 | "license": "MIT", 251 | "optional": true, 252 | "os": [ 253 | "linux" 254 | ], 255 | "engines": { 256 | "node": ">=12" 257 | } 258 | }, 259 | "node_modules/@esbuild/linux-riscv64": { 260 | "version": "0.21.5", 261 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 262 | "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 263 | "cpu": [ 264 | "riscv64" 265 | ], 266 | "dev": true, 267 | "license": "MIT", 268 | "optional": true, 269 | "os": [ 270 | "linux" 271 | ], 272 | "engines": { 273 | "node": ">=12" 274 | } 275 | }, 276 | "node_modules/@esbuild/linux-s390x": { 277 | "version": "0.21.5", 278 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 279 | "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 280 | "cpu": [ 281 | "s390x" 282 | ], 283 | "dev": true, 284 | "license": "MIT", 285 | "optional": true, 286 | "os": [ 287 | "linux" 288 | ], 289 | "engines": { 290 | "node": ">=12" 291 | } 292 | }, 293 | "node_modules/@esbuild/linux-x64": { 294 | "version": "0.21.5", 295 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 296 | "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 297 | "cpu": [ 298 | "x64" 299 | ], 300 | "dev": true, 301 | "license": "MIT", 302 | "optional": true, 303 | "os": [ 304 | "linux" 305 | ], 306 | "engines": { 307 | "node": ">=12" 308 | } 309 | }, 310 | "node_modules/@esbuild/netbsd-x64": { 311 | "version": "0.21.5", 312 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 313 | "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 314 | "cpu": [ 315 | "x64" 316 | ], 317 | "dev": true, 318 | "license": "MIT", 319 | "optional": true, 320 | "os": [ 321 | "netbsd" 322 | ], 323 | "engines": { 324 | "node": ">=12" 325 | } 326 | }, 327 | "node_modules/@esbuild/openbsd-x64": { 328 | "version": "0.21.5", 329 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 330 | "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 331 | "cpu": [ 332 | "x64" 333 | ], 334 | "dev": true, 335 | "license": "MIT", 336 | "optional": true, 337 | "os": [ 338 | "openbsd" 339 | ], 340 | "engines": { 341 | "node": ">=12" 342 | } 343 | }, 344 | "node_modules/@esbuild/sunos-x64": { 345 | "version": "0.21.5", 346 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 347 | "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 348 | "cpu": [ 349 | "x64" 350 | ], 351 | "dev": true, 352 | "license": "MIT", 353 | "optional": true, 354 | "os": [ 355 | "sunos" 356 | ], 357 | "engines": { 358 | "node": ">=12" 359 | } 360 | }, 361 | "node_modules/@esbuild/win32-arm64": { 362 | "version": "0.21.5", 363 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 364 | "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 365 | "cpu": [ 366 | "arm64" 367 | ], 368 | "dev": true, 369 | "license": "MIT", 370 | "optional": true, 371 | "os": [ 372 | "win32" 373 | ], 374 | "engines": { 375 | "node": ">=12" 376 | } 377 | }, 378 | "node_modules/@esbuild/win32-ia32": { 379 | "version": "0.21.5", 380 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 381 | "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 382 | "cpu": [ 383 | "ia32" 384 | ], 385 | "dev": true, 386 | "license": "MIT", 387 | "optional": true, 388 | "os": [ 389 | "win32" 390 | ], 391 | "engines": { 392 | "node": ">=12" 393 | } 394 | }, 395 | "node_modules/@esbuild/win32-x64": { 396 | "version": "0.21.5", 397 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 398 | "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 399 | "cpu": [ 400 | "x64" 401 | ], 402 | "dev": true, 403 | "license": "MIT", 404 | "optional": true, 405 | "os": [ 406 | "win32" 407 | ], 408 | "engines": { 409 | "node": ">=12" 410 | } 411 | }, 412 | "node_modules/@friendsofshopware/app-server": { 413 | "name": "@jsr/friendsofshopware__app-server", 414 | "version": "0.0.55", 415 | "resolved": "https://npm.jsr.io/~/11/@jsr/friendsofshopware__app-server/0.0.55.tgz", 416 | "integrity": "sha512-Oi6TL9z7pDo4AIjxFq4zTbKSmx7tQIMHh7Gk+jVAlK7Cm8WcSHb1vE7m9mBaEoAHQsEBreo4XtarQMa6WeWHTA==" 417 | }, 418 | "node_modules/@hono/node-server": { 419 | "version": "1.11.4", 420 | "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.11.4.tgz", 421 | "integrity": "sha512-8TOiiiAqcFC6f62P7M9p6adQREAlWdVi1awehAwgWW+3R65/rKzHnLARO/Hu/466z01VNViBoogqatqXJMyItA==", 422 | "license": "MIT", 423 | "engines": { 424 | "node": ">=18.14.1" 425 | } 426 | }, 427 | "node_modules/esbuild": { 428 | "version": "0.21.5", 429 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 430 | "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 431 | "dev": true, 432 | "hasInstallScript": true, 433 | "license": "MIT", 434 | "bin": { 435 | "esbuild": "bin/esbuild" 436 | }, 437 | "engines": { 438 | "node": ">=12" 439 | }, 440 | "optionalDependencies": { 441 | "@esbuild/aix-ppc64": "0.21.5", 442 | "@esbuild/android-arm": "0.21.5", 443 | "@esbuild/android-arm64": "0.21.5", 444 | "@esbuild/android-x64": "0.21.5", 445 | "@esbuild/darwin-arm64": "0.21.5", 446 | "@esbuild/darwin-x64": "0.21.5", 447 | "@esbuild/freebsd-arm64": "0.21.5", 448 | "@esbuild/freebsd-x64": "0.21.5", 449 | "@esbuild/linux-arm": "0.21.5", 450 | "@esbuild/linux-arm64": "0.21.5", 451 | "@esbuild/linux-ia32": "0.21.5", 452 | "@esbuild/linux-loong64": "0.21.5", 453 | "@esbuild/linux-mips64el": "0.21.5", 454 | "@esbuild/linux-ppc64": "0.21.5", 455 | "@esbuild/linux-riscv64": "0.21.5", 456 | "@esbuild/linux-s390x": "0.21.5", 457 | "@esbuild/linux-x64": "0.21.5", 458 | "@esbuild/netbsd-x64": "0.21.5", 459 | "@esbuild/openbsd-x64": "0.21.5", 460 | "@esbuild/sunos-x64": "0.21.5", 461 | "@esbuild/win32-arm64": "0.21.5", 462 | "@esbuild/win32-ia32": "0.21.5", 463 | "@esbuild/win32-x64": "0.21.5" 464 | } 465 | }, 466 | "node_modules/fsevents": { 467 | "version": "2.3.3", 468 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 469 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 470 | "dev": true, 471 | "hasInstallScript": true, 472 | "optional": true, 473 | "os": [ 474 | "darwin" 475 | ], 476 | "engines": { 477 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 478 | } 479 | }, 480 | "node_modules/get-tsconfig": { 481 | "version": "4.7.5", 482 | "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", 483 | "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", 484 | "dev": true, 485 | "license": "MIT", 486 | "dependencies": { 487 | "resolve-pkg-maps": "^1.0.0" 488 | }, 489 | "funding": { 490 | "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 491 | } 492 | }, 493 | "node_modules/hono": { 494 | "version": "4.5.8", 495 | "resolved": "https://registry.npmjs.org/hono/-/hono-4.5.8.tgz", 496 | "integrity": "sha512-pqpSlcdqGkpTTRpLYU1PnCz52gVr0zVR9H5GzMyJWuKQLLEBQxh96q45QizJ2PPX8NATtz2mu31/PKW/Jt+90Q==", 497 | "engines": { 498 | "node": ">=16.0.0" 499 | } 500 | }, 501 | "node_modules/resolve-pkg-maps": { 502 | "version": "1.0.0", 503 | "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 504 | "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 505 | "dev": true, 506 | "license": "MIT", 507 | "funding": { 508 | "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 509 | } 510 | }, 511 | "node_modules/tsx": { 512 | "version": "4.15.7", 513 | "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.15.7.tgz", 514 | "integrity": "sha512-u3H0iSFDZM3za+VxkZ1kywdCeHCn+8/qHQS1MNoO2sONDgD95HlWtt8aB23OzeTmFP9IU4/8bZUdg58Uu5J4cg==", 515 | "dev": true, 516 | "license": "MIT", 517 | "dependencies": { 518 | "esbuild": "~0.21.4", 519 | "get-tsconfig": "^4.7.5" 520 | }, 521 | "bin": { 522 | "tsx": "dist/cli.mjs" 523 | }, 524 | "engines": { 525 | "node": ">=18.0.0" 526 | }, 527 | "optionalDependencies": { 528 | "fsevents": "~2.3.3" 529 | } 530 | }, 531 | "node_modules/typescript": { 532 | "version": "5.5.2", 533 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", 534 | "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", 535 | "dev": true, 536 | "license": "Apache-2.0", 537 | "bin": { 538 | "tsc": "bin/tsc", 539 | "tsserver": "bin/tsserver" 540 | }, 541 | "engines": { 542 | "node": ">=14.17" 543 | } 544 | } 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /examples/node-hono/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopware-hono", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "tsx watch index.ts", 9 | "start": "tsx index.ts" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@friendsofshopware/app-server": "npm:@jsr/friendsofshopware__app-server@^0.0.55", 15 | "@hono/node-server": "^1.11.4", 16 | "hono": "^4.5.8" 17 | }, 18 | "devDependencies": { 19 | "tsx": "^4.15.7", 20 | "typescript": "^5.5.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Friends of Shopware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # App Server 2 | 3 | This package can be used to create a Shopware App Backend. It's build independent of any JavaScript framework. It relies on Fetch-standardized Request and Response objects. 4 | 5 | ## Standalone example with Bun 6 | 7 | ```js 8 | import { AppServer, InMemoryShopRepository } from '@friendsofshopware/app-server' 9 | import { createNotificationResponse } from '@friendsofshopware/app-server/helper/app-actions' 10 | 11 | const app = new AppServer({ 12 | appName: 'MyApp', 13 | appSecret: 'my-secret', 14 | authorizeCallbackUrl: 'http://localhost:3000/authorize/callback', 15 | }, new InMemoryShopRepository()); 16 | 17 | const server = Bun.serve({ 18 | port: 3000, 19 | async fetch(request) { 20 | const { pathname } = new URL(request.url); 21 | if (pathname === '/authorize') { 22 | return app.registration.authorize(request); 23 | } else if (pathname === '/authorize/callback') { 24 | return app.registration.authorizeCallback(request); 25 | } else if (pathname === '/app/product') { 26 | const context = await app.contextResolver.fromSource(request); 27 | 28 | // do something with payload, and http client 29 | 30 | const notification = createNotificationResponse('success', 'Product created'); 31 | 32 | // sign the response, with the shop secret 33 | await app.signer.signResponse(notification, context.shop.getShopSecret()); 34 | 35 | return resp; 36 | } 37 | 38 | return new Response('Not found', { status: 404 }); 39 | }, 40 | }); 41 | 42 | console.log(`Listening on localhost:${server.port}`); 43 | ``` 44 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Registration } from "./registration.ts"; 2 | import { WebCryptoHmacSigner } from "./signer.ts"; 3 | import { ShopRepositoryInterface } from "./repository.ts"; 4 | import { ContextResolver } from "./context-resolver.ts"; 5 | 6 | /** 7 | * AppServer is the main class, this is where you start your app 8 | */ 9 | export class AppServer { 10 | public registration: Registration; 11 | public contextResolver: ContextResolver; 12 | public signer: WebCryptoHmacSigner; 13 | 14 | constructor( 15 | public cfg: Configuration, 16 | public repository: ShopRepositoryInterface 17 | ) { 18 | this.registration = new Registration(this); 19 | this.contextResolver = new ContextResolver(this); 20 | this.signer = new WebCryptoHmacSigner() 21 | } 22 | } 23 | 24 | interface Configuration { 25 | /** 26 | * Your app name 27 | */ 28 | appName: string; 29 | 30 | /** 31 | * Your app secret 32 | */ 33 | appSecret: string; 34 | 35 | /** 36 | * URL to authorize callback url 37 | */ 38 | authorizeCallbackUrl: string; 39 | } 40 | -------------------------------------------------------------------------------- /src/context-resolver.ts: -------------------------------------------------------------------------------- 1 | import { AppServer } from "./app.ts"; 2 | import { HttpClient } from "./http-client.ts"; 3 | import { ShopInterface } from "./repository.ts"; 4 | 5 | /** 6 | * ContextResolver is a helper class to create a Context object from a request. 7 | * The context contains the shop, the payload and an instance of the HttpClient 8 | */ 9 | export class ContextResolver { 10 | constructor(private app: AppServer) {} 11 | 12 | /** 13 | * Create a context from a request bodty 14 | */ 15 | public async fromSource(req: Request): Promise { 16 | const webHookContent = await req.text(); 17 | const webHookBody = JSON.parse(webHookContent); 18 | 19 | const shop = await this.app.repository.getShopById( 20 | webHookBody.source.shopId, 21 | ); 22 | 23 | if (shop === null) { 24 | throw new Error(`Cannot find shop by id ${webHookBody.source.shopId}`); 25 | } 26 | 27 | await this.app.signer.verify( 28 | req.headers.get("shopware-shop-signature") as string, 29 | webHookContent, 30 | shop.getShopSecret(), 31 | ); 32 | 33 | return new Context(shop, webHookBody, new HttpClient(shop)); 34 | } 35 | 36 | /** 37 | * Create a context from a request query parameters 38 | * This is usually a module request from the shopware admin 39 | */ 40 | public async fromModule(req: Request): Promise { 41 | const url = new URL(req.url); 42 | 43 | const shop = await this.app.repository.getShopById( 44 | url.searchParams.get("shop-id") as string, 45 | ); 46 | if (shop === null) { 47 | throw new Error( 48 | `Cannot find shop by id ${url.searchParams.get("shop-id")}`, 49 | ); 50 | } 51 | 52 | await this.app.signer.verifyGetRequest(req, shop.getShopSecret()); 53 | 54 | const paramsObject: Record = {}; 55 | 56 | url.searchParams.forEach((value, key) => { 57 | paramsObject[key] = value; 58 | }); 59 | 60 | return new Context(shop, paramsObject, new HttpClient(shop)); 61 | } 62 | } 63 | 64 | /** 65 | * Context is the parsed data from the request 66 | */ 67 | export class Context { 68 | constructor( 69 | public shop: ShopInterface, 70 | public payload: any, 71 | public httpClient: HttpClient, 72 | ) { 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/express.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/app-server-sdk-js/87bc1dfe48eba408f73e898c1a7b39b6dcb12d51/src/express.ts -------------------------------------------------------------------------------- /src/framework/express.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deno KV integration 3 | * @module 4 | */ 5 | 6 | interface ExpressResponse { 7 | status: (status: number) => void; 8 | header: (key: string, value: string) => void; 9 | send: (body: string) => void; 10 | } 11 | 12 | interface ExpressRequest { 13 | protocol: string; 14 | get: (key: string) => string; 15 | originalUrl: string; 16 | headers: {[key: string]: string | string[]}; 17 | method: string; 18 | rawBody?: string; 19 | setEncoding: (encoding: string) => void; 20 | on: (event: string, callback: Function) => void; 21 | } 22 | 23 | /** 24 | * Converts a fetch Response to a Express Response 25 | */ 26 | export async function convertResponse(response: Response, expressResponse: ExpressResponse) { 27 | expressResponse.status(response.status); 28 | response.headers.forEach((val, key) => { 29 | expressResponse.header(key, val); 30 | }) 31 | 32 | expressResponse.send(await response.text()); 33 | } 34 | 35 | /** 36 | * Converts a Express request to a fetch Request 37 | */ 38 | export function convertRequest(expressRequest: ExpressRequest): Request { 39 | const headers = new Headers(); 40 | 41 | for (const [key, value] of Object.entries(expressRequest.headers)) { 42 | headers.set(key, value as string); 43 | } 44 | 45 | const options: {headers: Headers, method: string, body?: string} = { 46 | headers, 47 | method: expressRequest.method, 48 | }; 49 | 50 | if (expressRequest.rawBody) { 51 | options.body = expressRequest.rawBody; 52 | } 53 | 54 | return new Request(expressRequest.protocol + '://' + expressRequest.get('host') + expressRequest.originalUrl, options); 55 | } 56 | 57 | /** 58 | * Middleware to parse the raw body of a request and add it to the request object 59 | * This is required as we can compute only the hash of the raw body and a parsed body might be different 60 | */ 61 | export function rawRequestMiddleware(req: ExpressRequest, res: ExpressResponse, next: Function): void { 62 | const contentType = req.headers['content-type'] as string || '' 63 | , mime = contentType.split(';')[0]; 64 | 65 | if (mime != 'application/json') { 66 | return next(); 67 | } 68 | 69 | let data = ''; 70 | req.setEncoding('utf8'); 71 | 72 | req.on('data', function(chunk: string) { 73 | data += chunk; 74 | }); 75 | 76 | req.on('end', function() { 77 | req.rawBody = data; 78 | next(); 79 | }); 80 | } -------------------------------------------------------------------------------- /src/framework/hono.ts: -------------------------------------------------------------------------------- 1 | import { AppServer } from "../app.ts"; 2 | import type { Context } from "../context-resolver.ts"; 3 | import type { ShopInterface, ShopRepositoryInterface } from "../repository.ts"; 4 | 5 | interface MiddlewareConfig { 6 | appName: string | ((c: HonoContext) => string); 7 | appSecret: string | ((c: HonoContext) => string); 8 | appUrl?: string | null; 9 | registrationUrl?: string | null; 10 | registerConfirmationUrl?: string | null; 11 | appPath?: string | null; 12 | shopRepository: 13 | | ShopRepositoryInterface 14 | | ((c: HonoContext) => ShopRepositoryInterface); 15 | } 16 | 17 | interface DataTypes { 18 | app: AppServer; 19 | context: Context; 20 | shop: ShopInterface; 21 | } 22 | 23 | interface HonoContext { 24 | env: any; 25 | req: { 26 | path: string; 27 | method: string; 28 | url: string 29 | raw: Request; 30 | } 31 | header: (key: string, value: string) => void; 32 | res: Response; 33 | get(key: K): DataTypes[K]; 34 | set(key: K, value: DataTypes[K]): void; 35 | } 36 | 37 | interface Hono { 38 | use: (path: string, handler: (ctx: HonoContext, next: () => Promise) => void) => void; 39 | get: (path: string, handler: (ctx: HonoContext, next: () => void) => void) => void; 40 | post: (path: string, handler: (ctx: HonoContext, next: () => void) => void) => void; 41 | } 42 | 43 | let app: AppServer | null = null; 44 | 45 | /** 46 | * Configure the Hono server to handle the app registration and context resolution 47 | */ 48 | export function configureAppServer( 49 | honoExternal: unknown, 50 | cfg: MiddlewareConfig, 51 | ) { 52 | 53 | const hono = honoExternal as Hono; 54 | 55 | cfg.registrationUrl = cfg.registrationUrl || "/app/register"; 56 | cfg.registerConfirmationUrl = cfg.registerConfirmationUrl || 57 | "/app/register/confirm"; 58 | cfg.appPath = cfg.appPath || "/app/*"; 59 | 60 | hono.use("*", async (ctx, next) => { 61 | if (app === null) { 62 | const appUrl = cfg.appUrl || buildBaseUrl(ctx.req.url); 63 | 64 | if (typeof cfg.shopRepository === "function") { 65 | cfg.shopRepository = cfg.shopRepository(ctx); 66 | } 67 | 68 | if (typeof cfg.appName === "function") { 69 | cfg.appName = cfg.appName(ctx); 70 | } 71 | 72 | if (typeof cfg.appSecret === "function") { 73 | cfg.appSecret = cfg.appSecret(ctx); 74 | } 75 | 76 | app = new AppServer( 77 | { 78 | appName: cfg.appName, 79 | appSecret: cfg.appSecret, 80 | authorizeCallbackUrl: appUrl + cfg.registerConfirmationUrl, 81 | }, 82 | cfg.shopRepository, 83 | ); 84 | } 85 | 86 | ctx.set("app", app); 87 | 88 | await next(); 89 | }); 90 | 91 | hono.use(cfg.appPath, async (ctx, next) => { 92 | const app = ctx.get("app") as AppServer; 93 | 94 | // Don't validate signature for registration 95 | if ( 96 | ctx.req.path === cfg.registrationUrl || 97 | ctx.req.path === cfg.registerConfirmationUrl 98 | ) { 99 | await next(); 100 | return; 101 | } 102 | 103 | let context; 104 | try { 105 | context = ctx.req.method === "GET" 106 | ? await app.contextResolver.fromModule(ctx.req.raw) 107 | : await app.contextResolver.fromSource(ctx.req.raw); 108 | } catch (_e) { 109 | return jsonResponse({ message: "Invalid request" }, 400); 110 | } 111 | 112 | ctx.set("shop", context.shop); 113 | ctx.set("context", context); 114 | 115 | await next(); 116 | 117 | const cloned = ctx.res.clone(); 118 | 119 | await ctx.get("app").signer.signResponse( 120 | cloned, 121 | ctx.get("shop").getShopSecret(), 122 | ); 123 | 124 | ctx.header( 125 | "shopware-app-signature", 126 | cloned.headers.get("shopware-app-signature") as string, 127 | ); 128 | }); 129 | 130 | hono.get(cfg.registrationUrl, async (ctx) => { 131 | const app = ctx.get("app"); 132 | 133 | try { 134 | return await app.registration.authorize(ctx.req.raw); 135 | } catch (_e) { 136 | let reason = "unknown error" 137 | 138 | if (_e instanceof Error) { 139 | reason = _e.message 140 | } 141 | 142 | return jsonResponse({ message: `Could not register the shop due to ${reason}` }, 400); 143 | } 144 | }); 145 | 146 | hono.post(cfg.registerConfirmationUrl, async (ctx) => { 147 | const app = ctx.get("app"); 148 | 149 | try { 150 | return await app.registration.authorizeCallback(ctx.req.raw); 151 | } catch (_e) { 152 | let reason = "unknown error" 153 | 154 | if (_e instanceof Error) { 155 | reason = _e.message 156 | } 157 | 158 | return jsonResponse({ message: `Could not confirm the reigstration of the shop due to ${reason}` }, 400); 159 | } 160 | }); 161 | } 162 | 163 | function jsonResponse(body: object, status = 200): Response { 164 | return new Response(JSON.stringify(body), { 165 | status, 166 | headers: { 167 | "Content-Type": "application/json", 168 | }, 169 | }); 170 | } 171 | 172 | function buildBaseUrl(url: string): string { 173 | const u = new URL(url); 174 | 175 | return `${u.protocol}//${u.host}`; 176 | } 177 | -------------------------------------------------------------------------------- /src/helper/app-actions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Opens in the Administration a new tab to the given URL and adds the shop-id as query parameter with the signature 3 | */ 4 | export function createNewTabResponse(redirectUrl: string): Response { 5 | return new Response( 6 | JSON.stringify({ 7 | actionType: "openNewTab", 8 | payload: { 9 | redirectUrl, 10 | }, 11 | }), 12 | { 13 | headers: { 14 | "content-type": "application/json", 15 | }, 16 | }, 17 | ); 18 | } 19 | 20 | /** 21 | * Shows in the Administration a new notification with the given status and message 22 | */ 23 | export function createNotificationResponse(status: 'success'|'error'|'info'|'warning', message: string): Response { 24 | return new Response( 25 | JSON.stringify({ 26 | actionType: "notification", 27 | payload: { 28 | status, 29 | message, 30 | }, 31 | }), 32 | { 33 | headers: { 34 | "content-type": "application/json", 35 | } 36 | } 37 | ) 38 | } 39 | 40 | /** 41 | * Opens in the Administration a new modal with the given iframe URL 42 | */ 43 | export function createModalResponse(iframeUrl: string, size: 'small' | 'medium'|"large"|'fullscreen' = 'medium', expand: boolean = false): Response 44 | { 45 | return new Response( 46 | JSON.stringify({ 47 | actionType: "openModal", 48 | payload: { 49 | iframeUrl, 50 | size, 51 | expand 52 | } 53 | }), 54 | { 55 | headers: { 56 | "content-type": "application/json", 57 | } 58 | } 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/http-client.ts: -------------------------------------------------------------------------------- 1 | import { ShopInterface } from "./repository.ts"; 2 | 3 | /** 4 | * HttpClient is a simple wrapper around the fetch API, pre-configured with the shop's URL and access token 5 | */ 6 | export class HttpClient { 7 | private storage: { expiresIn: Date | null; token: string | null }; 8 | 9 | constructor(private shop: ShopInterface) { 10 | this.storage = { 11 | token: null, 12 | expiresIn: null, 13 | }; 14 | } 15 | 16 | /** 17 | * Permform a GET request 18 | */ 19 | async get(url: string, headers: object = {}): Promise { 20 | return await this.request("GET", url, null, headers); 21 | } 22 | 23 | /** 24 | * Permform a POST request 25 | */ 26 | async post( 27 | url: string, 28 | json: object = {}, 29 | headers: any = {}, 30 | ): Promise { 31 | headers["content-type"] = "application/json"; 32 | headers["accept"] = "application/json"; 33 | 34 | return await this.request("POST", url, JSON.stringify(json), headers); 35 | } 36 | 37 | /** 38 | * Permform a PUT request 39 | */ 40 | async put( 41 | url: string, 42 | json: object = {}, 43 | headers: any = {}, 44 | ): Promise { 45 | headers["content-type"] = "application/json"; 46 | headers["accept"] = "application/json"; 47 | 48 | return await this.request("PUT", url, JSON.stringify(json), headers); 49 | } 50 | 51 | /** 52 | * Permform a PATCH request 53 | */ 54 | async patch( 55 | url: string, 56 | json: object = {}, 57 | headers: any = {}, 58 | ): Promise { 59 | headers["content-type"] = "application/json"; 60 | headers["accept"] = "application/json"; 61 | 62 | return await this.request("PATCH", url, JSON.stringify(json), headers); 63 | } 64 | 65 | /** 66 | * Permform a DELETE request 67 | */ 68 | async delete( 69 | url: string, 70 | json: object = {}, 71 | headers: any = {}, 72 | ): Promise { 73 | headers["content-type"] = "application/json"; 74 | headers["accept"] = "application/json"; 75 | 76 | return await this.request("DELETE", url, JSON.stringify(json), headers); 77 | } 78 | 79 | private async request( 80 | method: string, 81 | url: string, 82 | body: string | null = "", 83 | headers: object = {}, 84 | ): Promise { 85 | const fHeaders: any = Object.assign({}, headers); 86 | fHeaders["Authorization"] = `Bearer ${await this.getToken()}`; 87 | 88 | const f = await globalThis.fetch( 89 | `${this.shop.getShopUrl()}/api${url}`, 90 | { 91 | body, 92 | headers: fHeaders, 93 | method, 94 | }, 95 | ); 96 | 97 | // Obtain new token 98 | if (!f.ok && f.status === 401) { 99 | this.storage.expiresIn = null; 100 | 101 | return await this.request(method, url, body, headers); 102 | } else if (!f.ok) { 103 | throw new ApiClientRequestFailed( 104 | this.shop.getShopId(), 105 | new HttpClientResponse(f.status, await f.json(), f.headers), 106 | ); 107 | } 108 | 109 | if (f.status === 204) { 110 | return new HttpClientResponse(f.status, {}, f.headers); 111 | } 112 | 113 | return new HttpClientResponse(f.status, await f.json(), f.headers); 114 | } 115 | 116 | /** 117 | * Obtain a valid bearer token 118 | */ 119 | async getToken(): Promise { 120 | if (this.storage.expiresIn === null) { 121 | const auth = await globalThis.fetch( 122 | `${this.shop.getShopUrl()}/api/oauth/token`, 123 | { 124 | method: "POST", 125 | headers: { 126 | "content-type": "application/json", 127 | }, 128 | body: JSON.stringify({ 129 | grant_type: "client_credentials", 130 | client_id: this.shop.getShopClientId(), 131 | client_secret: this.shop.getShopClientSecret(), 132 | }), 133 | }, 134 | ); 135 | 136 | if (!auth.ok) { 137 | const contentType = auth.headers.get('content-type') || 'text/plain'; 138 | let body = ''; 139 | 140 | if (contentType.indexOf('application/json') != -1) { 141 | body = await auth.json(); 142 | } else { 143 | body = await auth.text(); 144 | } 145 | 146 | throw new ApiClientAuthenticationFailed( 147 | this.shop.getShopId(), 148 | new HttpClientResponse(auth.status, body, auth.headers), 149 | ); 150 | } 151 | 152 | const expireDate = new Date(); 153 | const authBody = await auth.json() as { 154 | access_token: string; 155 | expires_in: number; 156 | }; 157 | this.storage.token = authBody.access_token; 158 | expireDate.setSeconds(expireDate.getSeconds() + authBody.expires_in); 159 | this.storage.expiresIn = expireDate; 160 | 161 | return this.storage.token as string; 162 | } 163 | 164 | if (this.storage.expiresIn.getTime() < (new Date()).getTime()) { 165 | // Expired 166 | 167 | this.storage.expiresIn = null; 168 | 169 | return await this.getToken(); 170 | } 171 | 172 | return this.storage.token as string; 173 | } 174 | } 175 | 176 | /** 177 | * HttpClientResponse is the response object of the HttpClient 178 | */ 179 | export class HttpClientResponse { 180 | constructor( 181 | public statusCode: number, 182 | public body: any, 183 | public headers: Headers, 184 | ) { 185 | } 186 | } 187 | 188 | /** 189 | * ApiClientAuthenticationFailed is thrown when the authentication to the shop's API fails 190 | */ 191 | export class ApiClientAuthenticationFailed extends Error { 192 | constructor(shopId: string, public response: HttpClientResponse) { 193 | super(`The api client authentication to shop with id: ${shopId}`); 194 | } 195 | } 196 | 197 | /** 198 | * ApiClientRequestFailed is thrown when the request to the shop's API fails 199 | */ 200 | export class ApiClientRequestFailed extends Error { 201 | constructor(shopId: string, public response: HttpClientResponse) { 202 | super( 203 | `The api request failed with status code: ${response.statusCode} for shop with id: ${shopId}`, 204 | ); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://jsr.io/schema/config-file.v1.json", 3 | "name": "@friendsofshopware/app-server", 4 | "version": "0.0.48", 5 | "exports": { 6 | ".": "./mod.ts", 7 | "./service/cloudflare-workers": "./service/cloudflare.ts", 8 | "./service/deno": "./service/deno.ts", 9 | "./framework/express": "./framework/express.ts", 10 | "./framework/hono": "./framework/hono.ts", 11 | "./helper/app-actions": "./helper/app-actions.ts" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export { AppServer } from "./app.ts"; 2 | export { InMemoryShopRepository, SimpleShop } from "./repository.ts" 3 | export type { ShopInterface, ShopRepositoryInterface } from "./repository.ts" 4 | export { HttpClient, HttpClientResponse, ApiClientAuthenticationFailed, ApiClientRequestFailed } from "./http-client.ts" 5 | export { Context } from "./context-resolver.ts" 6 | -------------------------------------------------------------------------------- /src/registration.ts: -------------------------------------------------------------------------------- 1 | import { AppServer } from "./app.ts"; 2 | 3 | export class Registration { 4 | constructor(private app: AppServer) {} 5 | 6 | /** 7 | * This method checks the request for the handshake with the Shopware Shop. 8 | * if it's valid a Shop will be created, and a proof will be responded with a confirmation url. 9 | * then the Shop will call the confirmation url, and this should be handled by the authorizeCallback method to finish the handshake. 10 | */ 11 | public async authorize(req: Request): Promise { 12 | const url = new URL(req.url); 13 | 14 | if ( 15 | !url.searchParams.has("shop-url") || 16 | !req.headers.has("shopware-app-signature") || 17 | !url.searchParams.has("shop-id") || 18 | !url.searchParams.has("timestamp") 19 | ) { 20 | return new InvalidRequestResponse('Invalid Request'); 21 | } 22 | 23 | const v = await this.app.signer.verify( 24 | req.headers.get("shopware-app-signature") as string, 25 | `shop-id=${url.searchParams.get("shop-id")}&shop-url=${ 26 | url.searchParams.get("shop-url") 27 | }×tamp=${url.searchParams.get("timestamp")}`, 28 | this.app.cfg.appSecret, 29 | ); 30 | 31 | if (!v) { 32 | return new InvalidRequestResponse('Cannot validate app signature'); 33 | } 34 | 35 | const shop = this.app.repository.createShopStruct( 36 | url.searchParams.get("shop-id") as string, 37 | url.searchParams.get("shop-url") as string, 38 | randomString(), 39 | ); 40 | 41 | await this.app.repository.createShop(shop); 42 | 43 | return new Response( 44 | JSON.stringify({ 45 | proof: await this.app.signer.sign( 46 | shop.getShopId() + shop.getShopUrl() + this.app.cfg.appName, 47 | this.app.cfg.appSecret, 48 | ), 49 | secret: shop.getShopSecret(), 50 | confirmation_url: this.app.cfg.authorizeCallbackUrl, 51 | }), 52 | { 53 | headers: { 54 | "content-type": "application/json", 55 | }, 56 | }, 57 | ); 58 | } 59 | 60 | /** 61 | * This method is called by the Shopware Shop to confirm the handshake. 62 | * It will update the shop with the given oauth2 credentials. 63 | */ 64 | public async authorizeCallback( 65 | req: Request 66 | ): Promise { 67 | const bodyContent = await req.text(); 68 | const body = JSON.parse(bodyContent); 69 | 70 | if ( 71 | typeof body.shopId !== "string" || typeof body.apiKey !== "string" || 72 | typeof body.secretKey !== "string" || 73 | !req.headers.has("shopware-shop-signature") 74 | ) { 75 | return new InvalidRequestResponse('Invalid Request'); 76 | } 77 | 78 | const shop = await this.app.repository.getShopById(body.shopId as string); 79 | 80 | if (shop === null) { 81 | return new InvalidRequestResponse('Invalid shop given'); 82 | } 83 | 84 | const v = await this.app.signer.verify( 85 | req.headers.get("shopware-shop-signature") as string, 86 | bodyContent, 87 | shop.getShopSecret(), 88 | ); 89 | if (!v) { 90 | // Shop has failed the verify. Delete it from our DB 91 | await this.app.repository.deleteShop(shop.getShopId()); 92 | 93 | return new InvalidRequestResponse('Cannot validate app signature'); 94 | } 95 | 96 | shop.setShopCredentials(body.apiKey, body.secretKey); 97 | 98 | await this.app.repository.updateShop(shop); 99 | 100 | return new Response(null, { status: 204 }); 101 | } 102 | } 103 | 104 | export function randomString(length = 120) { 105 | let result = ''; 106 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 107 | const charactersLength = characters.length; 108 | for ( var i = 0; i < length; i++ ) { 109 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 110 | } 111 | 112 | return result; 113 | } 114 | 115 | class InvalidRequestResponse extends Response { 116 | constructor(message: string, status = 401) { 117 | super(JSON.stringify({ message }), { 118 | status, 119 | headers: { 120 | "content-type": "application/json", 121 | }, 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/repository.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ShopInterface defines the object that given back from the ShopRepository, it should methods to get the shop data and set them 3 | */ 4 | export interface ShopInterface { 5 | getShopId(): string; 6 | getShopUrl(): string; 7 | getShopSecret(): string; 8 | getShopClientId(): string | null; 9 | getShopClientSecret(): string | null; 10 | setShopCredentials(clientId: string, clientSecret: string): void; 11 | } 12 | 13 | /** 14 | * ShopRepositoryInterface is the storage interface for the shops, you should implement this to save the shop data to your database 15 | * For testing cases the InMemoryShopRepository can be used 16 | */ 17 | export interface ShopRepositoryInterface { 18 | createShopStruct(shopId: string, shopUrl: string, shopSecret: string): ShopInterface; 19 | 20 | createShop(shop: ShopInterface): Promise; 21 | 22 | getShopById(id: string): Promise; 23 | 24 | updateShop(shop: ShopInterface): Promise; 25 | 26 | deleteShop(id: string): Promise; 27 | } 28 | 29 | /** 30 | * SimpleShop is a simple implementation of the ShopInterface, it stores the shop data in memory 31 | */ 32 | export class SimpleShop implements ShopInterface { 33 | private shopId: string; 34 | private shopUrl: string; 35 | private shopSecret: string; 36 | private shopClientId: string | null; 37 | private shopClientSecret: string | null; 38 | 39 | constructor(shopId: string, shopUrl: string, shopSecret: string) { 40 | this.shopId = shopId; 41 | this.shopUrl = shopUrl; 42 | this.shopSecret = shopSecret; 43 | this.shopClientId = null; 44 | this.shopClientSecret = null; 45 | } 46 | 47 | getShopId(): string { 48 | return this.shopId; 49 | } 50 | getShopUrl(): string { 51 | return this.shopUrl; 52 | } 53 | getShopSecret(): string { 54 | return this.shopSecret; 55 | } 56 | getShopClientId(): string | null { 57 | return this.shopClientId; 58 | } 59 | getShopClientSecret(): string | null { 60 | return this.shopClientSecret; 61 | } 62 | setShopCredentials(clientId: string, clientSecret: string): void { 63 | this.shopClientId = clientId; 64 | this.shopClientSecret = clientSecret; 65 | } 66 | } 67 | 68 | /** 69 | * InMemoryShopRepository is a simple implementation of the ShopRepositoryInterface, it stores the shop data in memory 70 | */ 71 | export class InMemoryShopRepository implements ShopRepositoryInterface { 72 | private storage: Map; 73 | 74 | constructor() { 75 | this.storage = new Map(); 76 | } 77 | 78 | createShopStruct(shopId: string, shopUrl: string, shopSecret: string): ShopInterface { 79 | return new SimpleShop(shopId, shopUrl, shopSecret); 80 | } 81 | 82 | async createShop(shop: ShopInterface) { 83 | this.storage.set(shop.getShopId(), shop); 84 | } 85 | 86 | async getShopById(id: string): Promise { 87 | if (this.storage.has(id)) { 88 | return this.storage.get(id) as ShopInterface; 89 | } 90 | 91 | return null; 92 | } 93 | 94 | async updateShop(shop: ShopInterface) { 95 | await this.createShop(shop); 96 | } 97 | 98 | async deleteShop(id: string) { 99 | this.storage.delete(id); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/service/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import { SimpleShop } from "../repository.ts"; 2 | import type { 3 | ShopInterface, 4 | ShopRepositoryInterface, 5 | } from "../repository.ts"; 6 | 7 | /** 8 | * Cloudflare KV integration 9 | * @module 10 | */ 11 | 12 | /** 13 | * Cloudflare KV implementation of the ShopRepositoryInterface 14 | */ 15 | export class CloudflareShopRepository implements ShopRepositoryInterface { 16 | constructor(private storage: KVNamespace) { 17 | this.storage = storage; 18 | } 19 | 20 | createShopStruct( 21 | shopId: string, 22 | shopUrl: string, 23 | shopSecret: string, 24 | ): ShopInterface { 25 | return new SimpleShop(shopId, shopUrl, shopSecret); 26 | } 27 | 28 | async createShop(shop: ShopInterface): Promise { 29 | await this.storage.put(shop.getShopId(), this.serializeShop(shop)); 30 | } 31 | 32 | async deleteShop(id: string): Promise { 33 | await this.storage.delete(id); 34 | } 35 | 36 | async getShopById(id: string): Promise { 37 | const kvObj = await this.storage.get(id); 38 | 39 | if (kvObj === null) { 40 | return null; 41 | } 42 | 43 | return this.deserializeShop(kvObj); 44 | } 45 | 46 | async updateShop(shop: ShopInterface): Promise { 47 | return await this.createShop(shop); 48 | } 49 | 50 | protected serializeShop(shop: ShopInterface): string { 51 | return JSON.stringify(shop); 52 | } 53 | 54 | protected deserializeShop(data: string): ShopInterface { 55 | const obj = JSON.parse(data); 56 | 57 | const shop = new SimpleShop( 58 | obj.shopId || "", 59 | obj.shopUrl || "", 60 | obj.shopSecret || "", 61 | ); 62 | 63 | shop.setShopCredentials( 64 | obj.shopClientId || "", 65 | obj.shopClientSecret || "", 66 | ); 67 | return shop; 68 | } 69 | } 70 | 71 | /** 72 | * Cloudflare KV 73 | */ 74 | declare interface KVNamespace { 75 | get( 76 | key: Key, 77 | options?: Partial>, 78 | ): Promise; 79 | put( 80 | key: Key, 81 | value: string | ArrayBuffer | ArrayBufferView | ReadableStream, 82 | options?: KVNamespacePutOptions, 83 | ): Promise; 84 | delete(key: Key): Promise; 85 | } 86 | 87 | /** 88 | * Cloudflare KV get options 89 | */ 90 | declare interface KVNamespaceGetOptions { 91 | type: Type; 92 | cacheTtl?: number; 93 | } 94 | 95 | /** 96 | * Cloudflare KV put options 97 | */ 98 | declare interface KVNamespacePutOptions { 99 | expiration?: number; 100 | expirationTtl?: number; 101 | metadata?: any | null; 102 | } 103 | -------------------------------------------------------------------------------- /src/service/deno.ts: -------------------------------------------------------------------------------- 1 | import { SimpleShop } from "../repository.ts"; 2 | import type { 3 | ShopInterface, 4 | ShopRepositoryInterface, 5 | } from "../repository.ts"; 6 | 7 | /** 8 | * Deno KV integration 9 | * @module 10 | */ 11 | 12 | 13 | /** 14 | * DenoKVRepository is a ShopRepositoryInterface implementation that uses the Deno KV storage to save the shop data 15 | */ 16 | export class DenoKVRepository implements ShopRepositoryInterface { 17 | constructor(private namespace = "shops") {} 18 | 19 | createShopStruct( 20 | shopId: string, 21 | shopUrl: string, 22 | shopSecret: string, 23 | ): ShopInterface { 24 | return new SimpleShop(shopId, shopUrl, shopSecret); 25 | } 26 | 27 | async createShop(shop: ShopInterface): Promise { 28 | // @ts-ignore 29 | const kv = await Deno.openKv(); 30 | 31 | await kv.set([this.namespace, shop.getShopId()], shop); 32 | } 33 | 34 | async getShopById(id: string): Promise { 35 | // @ts-ignore 36 | const kv = await Deno.openKv(); 37 | 38 | const result = await kv.get([this.namespace, id]); 39 | 40 | if (result.key === null) { 41 | return null; 42 | } 43 | 44 | const data = result.value as { 45 | shopId: string; 46 | shopUrl: string; 47 | shopSecret: string; 48 | shopClientId: string | null; 49 | shopClientSecret: string | null; 50 | }; 51 | 52 | const shop = new SimpleShop(data.shopId, data.shopUrl, data.shopSecret); 53 | 54 | if (data.shopClientId && data.shopClientSecret) { 55 | shop.setShopCredentials(data.shopClientId, data.shopClientSecret); 56 | } 57 | 58 | return shop; 59 | } 60 | 61 | async updateShop(shop: ShopInterface): Promise { 62 | await this.createShop(shop); 63 | } 64 | 65 | async deleteShop(id: string): Promise { 66 | // @ts-ignore 67 | const kv = await Deno.openKv(); 68 | 69 | await kv.delete([this.namespace, id]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/signer.ts: -------------------------------------------------------------------------------- 1 | export class WebCryptoHmacSigner { 2 | private encoder: TextEncoder; 3 | private keyCache: Map; 4 | 5 | constructor() { 6 | this.encoder = new TextEncoder(); 7 | this.keyCache = new Map(); 8 | } 9 | 10 | async signResponse(response: Response, secret: string): Promise { 11 | response.headers.set( 12 | "shopware-app-signature", 13 | await this.sign(await response.text(), secret), 14 | ); 15 | } 16 | 17 | async verifyGetRequest(request: Request, secret: string): Promise { 18 | const url = new URL(request.url); 19 | const signature = url.searchParams.get("shopware-shop-signature") as string; 20 | url.searchParams.delete("shopware-shop-signature"); 21 | 22 | return await this.verify(signature, url.searchParams.toString(), secret); 23 | } 24 | 25 | async getKeyForSecret(secret: string): Promise { 26 | if (this.keyCache.has(secret)) { 27 | return this.keyCache.get(secret) as CryptoKey; 28 | } 29 | 30 | const secretKeyData = this.encoder.encode(secret); 31 | const key = await crypto.subtle.importKey( 32 | "raw", 33 | secretKeyData, 34 | { name: "HMAC", hash: "SHA-256" }, 35 | false, 36 | ["sign", "verify"], 37 | ); 38 | 39 | this.keyCache.set(secret, key); 40 | 41 | return key; 42 | } 43 | 44 | async sign(message: string, secret: string): Promise { 45 | const key = await this.getKeyForSecret(secret); 46 | 47 | const mac = await crypto.subtle.sign( 48 | "HMAC", 49 | key as CryptoKey, 50 | this.encoder.encode(message), 51 | ); 52 | 53 | return this.buf2hex(mac); 54 | } 55 | 56 | async verify(signature: string, data: string, secret: string): Promise { 57 | const signed = await this.sign(data, secret); 58 | 59 | return signature === signed; 60 | } 61 | 62 | buf2hex(buf: ArrayBuffer): string { 63 | return Array.prototype.map.call( 64 | new Uint8Array(buf), 65 | (x) => (("00" + x.toString(16)).slice(-2)), 66 | ).join(""); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "nodenext", 5 | "lib": ["es2021", "webworker"], 6 | "declaration": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "allowImportingTsExtensions": true 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "dist", 15 | "examples" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------