├── .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 |
--------------------------------------------------------------------------------