@tollwyn/baukasten 0.0.2 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +254 -1
  2. package/dist/bin/check.d.ts +2 -0
  3. package/dist/bin/check.js +38 -0
  4. package/dist/bin/migrate.d.ts +2 -0
  5. package/dist/bin/migrate.js +111 -0
  6. package/dist/bin/stubs.d.ts +2 -0
  7. package/dist/bin/stubs.js +38 -0
  8. package/dist/bin/test-accounts.d.ts +2 -0
  9. package/dist/bin/test-accounts.js +62 -0
  10. package/dist/check.d.ts +20 -0
  11. package/dist/check.js +285 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +9 -0
  14. package/dist/product.d.ts +161 -0
  15. package/dist/product.js +152 -0
  16. package/dist/server/accounts.d.ts +128 -0
  17. package/dist/server/accounts.js +282 -0
  18. package/dist/server/adapter/db.d.ts +5 -0
  19. package/dist/server/adapter/db.js +63 -0
  20. package/dist/server/adapter/log.d.ts +3 -0
  21. package/dist/server/adapter/log.js +12 -0
  22. package/dist/server/adapter/mailer.d.ts +4 -0
  23. package/dist/server/adapter/mailer.js +56 -0
  24. package/dist/server/adapter/migrate.d.ts +21 -0
  25. package/dist/server/adapter/migrate.js +113 -0
  26. package/dist/server/adapter/secrets.d.ts +2 -0
  27. package/dist/server/adapter/secrets.js +40 -0
  28. package/dist/server/adapter/stripe.d.ts +11 -0
  29. package/dist/server/adapter/stripe.js +333 -0
  30. package/dist/server/api.d.ts +25 -0
  31. package/dist/server/api.js +98 -0
  32. package/dist/server/baukasten-migrations.d.ts +4 -0
  33. package/dist/server/baukasten-migrations.js +126 -0
  34. package/dist/server/billing.d.ts +94 -0
  35. package/dist/server/billing.js +349 -0
  36. package/dist/server/catalog.d.ts +39 -0
  37. package/dist/server/catalog.js +193 -0
  38. package/dist/server/config.d.ts +108 -0
  39. package/dist/server/config.js +244 -0
  40. package/dist/server/endpoints.d.ts +44 -0
  41. package/dist/server/endpoints.js +159 -0
  42. package/dist/server/fields.d.ts +7 -0
  43. package/dist/server/fields.js +18 -0
  44. package/dist/server/headers.d.ts +1 -0
  45. package/dist/server/headers.js +9 -0
  46. package/dist/server/health.d.ts +34 -0
  47. package/dist/server/health.js +116 -0
  48. package/dist/server/hook.d.ts +92 -0
  49. package/dist/server/hook.js +293 -0
  50. package/dist/server/index.d.ts +18 -0
  51. package/dist/server/index.js +11 -0
  52. package/dist/server/legal/de.d.ts +2 -0
  53. package/dist/server/legal/de.js +251 -0
  54. package/dist/server/legal/document.d.ts +37 -0
  55. package/dist/server/legal/document.js +30 -0
  56. package/dist/server/legal/en.d.ts +2 -0
  57. package/dist/server/legal/en.js +251 -0
  58. package/dist/server/legal/fleet.d.ts +114 -0
  59. package/dist/server/legal/fleet.js +89 -0
  60. package/dist/server/legal/index.d.ts +3 -0
  61. package/dist/server/legal/index.js +11 -0
  62. package/dist/server/limits.d.ts +58 -0
  63. package/dist/server/limits.js +76 -0
  64. package/dist/server/log.d.ts +26 -0
  65. package/dist/server/log.js +61 -0
  66. package/dist/server/machine.d.ts +4 -0
  67. package/dist/server/machine.js +57 -0
  68. package/dist/server/mail.d.ts +20 -0
  69. package/dist/server/mail.js +29 -0
  70. package/dist/server/messages.d.ts +105 -0
  71. package/dist/server/messages.js +232 -0
  72. package/dist/server/migrations.d.ts +28 -0
  73. package/dist/server/migrations.js +59 -0
  74. package/dist/server/openapi.d.ts +24 -0
  75. package/dist/server/openapi.js +173 -0
  76. package/dist/server/pages.d.ts +158 -0
  77. package/dist/server/pages.js +491 -0
  78. package/dist/server/ports.d.ts +273 -0
  79. package/dist/server/ports.js +1 -0
  80. package/dist/server/runs.d.ts +30 -0
  81. package/dist/server/runs.js +55 -0
  82. package/dist/server/sales.d.ts +38 -0
  83. package/dist/server/sales.js +132 -0
  84. package/dist/server/services.d.ts +57 -0
  85. package/dist/server/services.js +44 -0
  86. package/dist/server/start.d.ts +14 -0
  87. package/dist/server/start.js +65 -0
  88. package/dist/server/stripe.d.ts +33 -0
  89. package/dist/server/stripe.js +29 -0
  90. package/dist/server/terms.d.ts +7 -0
  91. package/dist/server/terms.js +9 -0
  92. package/dist/server/test-accounts.d.ts +28 -0
  93. package/dist/server/test-accounts.js +62 -0
  94. package/dist/server/via.d.ts +9 -0
  95. package/dist/server/via.js +15 -0
  96. package/dist/server/web.d.ts +48 -0
  97. package/dist/server/web.js +105 -0
  98. package/dist/shopify/adapter/shopify.d.ts +8 -0
  99. package/dist/shopify/adapter/shopify.js +232 -0
  100. package/dist/shopify/admin.d.ts +33 -0
  101. package/dist/shopify/admin.js +47 -0
  102. package/dist/shopify/app.d.ts +60 -0
  103. package/dist/shopify/app.js +67 -0
  104. package/dist/shopify/config.d.ts +53 -0
  105. package/dist/shopify/config.js +133 -0
  106. package/dist/shopify/constants.d.ts +27 -0
  107. package/dist/shopify/constants.js +27 -0
  108. package/dist/shopify/headers.d.ts +1 -0
  109. package/dist/shopify/headers.js +8 -0
  110. package/dist/shopify/hmac.d.ts +1 -0
  111. package/dist/shopify/hmac.js +9 -0
  112. package/dist/shopify/hook.d.ts +60 -0
  113. package/dist/shopify/hook.js +508 -0
  114. package/dist/shopify/id-token.d.ts +12 -0
  115. package/dist/shopify/id-token.js +66 -0
  116. package/dist/shopify/index.d.ts +19 -0
  117. package/dist/shopify/index.js +15 -0
  118. package/dist/shopify/legal/de.d.ts +3 -0
  119. package/dist/shopify/legal/de.js +123 -0
  120. package/dist/shopify/legal/en.d.ts +3 -0
  121. package/dist/shopify/legal/en.js +121 -0
  122. package/dist/shopify/legal/index.d.ts +19 -0
  123. package/dist/shopify/legal/index.js +8 -0
  124. package/dist/shopify/legal/shopify.d.ts +16 -0
  125. package/dist/shopify/legal/shopify.js +21 -0
  126. package/dist/shopify/migrations.d.ts +4 -0
  127. package/dist/shopify/migrations.js +67 -0
  128. package/dist/shopify/pages.d.ts +45 -0
  129. package/dist/shopify/pages.js +146 -0
  130. package/dist/shopify/ports.d.ts +77 -0
  131. package/dist/shopify/ports.js +1 -0
  132. package/dist/shopify/sales.d.ts +4 -0
  133. package/dist/shopify/sales.js +27 -0
  134. package/dist/shopify/scopes.d.ts +2 -0
  135. package/dist/shopify/scopes.js +13 -0
  136. package/dist/shopify/seal.d.ts +12 -0
  137. package/dist/shopify/seal.js +60 -0
  138. package/dist/shopify/shop.d.ts +4 -0
  139. package/dist/shopify/shop.js +15 -0
  140. package/dist/shopify/shops.d.ts +76 -0
  141. package/dist/shopify/shops.js +256 -0
  142. package/dist/shopify/sveltekit.d.ts +19 -0
  143. package/dist/shopify/sveltekit.js +18 -0
  144. package/dist/shopify/texts.d.ts +28 -0
  145. package/dist/shopify/texts.js +49 -0
  146. package/dist/shopify/toml.d.ts +22 -0
  147. package/dist/shopify/toml.js +125 -0
  148. package/dist/shopify/webhooks.d.ts +65 -0
  149. package/dist/shopify/webhooks.js +220 -0
  150. package/dist/stubs.d.ts +15 -0
  151. package/dist/stubs.js +287 -0
  152. package/dist/sveltekit.d.ts +20 -0
  153. package/dist/sveltekit.js +18 -0
  154. package/dist/testing/app.d.ts +36 -0
  155. package/dist/testing/app.js +155 -0
  156. package/dist/testing/browser.d.ts +21 -0
  157. package/dist/testing/browser.js +21 -0
  158. package/dist/testing/connection.d.ts +7 -0
  159. package/dist/testing/connection.js +303 -0
  160. package/dist/testing/database.d.ts +21 -0
  161. package/dist/testing/database.js +54 -0
  162. package/dist/testing/env-example.d.ts +4 -0
  163. package/dist/testing/env-example.js +33 -0
  164. package/dist/testing/index.d.ts +9 -0
  165. package/dist/testing/index.js +9 -0
  166. package/dist/testing/shopify-connection.d.ts +8 -0
  167. package/dist/testing/shopify-connection.js +313 -0
  168. package/dist/testing/shopify.d.ts +71 -0
  169. package/dist/testing/shopify.js +278 -0
  170. package/dist/testing/smtp.d.ts +38 -0
  171. package/dist/testing/smtp.js +165 -0
  172. package/dist/testing/stripe.d.ts +128 -0
  173. package/dist/testing/stripe.js +511 -0
  174. package/dist/texts.d.ts +139 -0
  175. package/dist/texts.js +265 -0
  176. package/dist/ui/AccountPage.svelte +143 -0
  177. package/dist/ui/AccountPage.svelte.d.ts +10 -0
  178. package/dist/ui/ConfirmEmailPage.svelte +21 -0
  179. package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
  180. package/dist/ui/ErrorPage.svelte +14 -0
  181. package/dist/ui/ErrorPage.svelte.d.ts +3 -0
  182. package/dist/ui/Field.svelte +12 -0
  183. package/dist/ui/Field.svelte.d.ts +10 -0
  184. package/dist/ui/ForgotPasswordPage.svelte +32 -0
  185. package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
  186. package/dist/ui/Form.svelte +30 -0
  187. package/dist/ui/Form.svelte.d.ts +11 -0
  188. package/dist/ui/Frame.svelte +84 -0
  189. package/dist/ui/Frame.svelte.d.ts +12 -0
  190. package/dist/ui/LegalPage.svelte +38 -0
  191. package/dist/ui/LegalPage.svelte.d.ts +9 -0
  192. package/dist/ui/Linked.svelte +5 -0
  193. package/dist/ui/Linked.svelte.d.ts +8 -0
  194. package/dist/ui/LoginPage.svelte +41 -0
  195. package/dist/ui/LoginPage.svelte.d.ts +10 -0
  196. package/dist/ui/LogoutPage.svelte +19 -0
  197. package/dist/ui/LogoutPage.svelte.d.ts +9 -0
  198. package/dist/ui/ResetPasswordPage.svelte +30 -0
  199. package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
  200. package/dist/ui/SignupPage.svelte +52 -0
  201. package/dist/ui/SignupPage.svelte.d.ts +10 -0
  202. package/dist/ui/VerifyEmailPage.svelte +50 -0
  203. package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
  204. package/dist/ui/base.css +287 -0
  205. package/dist/ui/data.d.ts +167 -0
  206. package/dist/ui/data.js +1 -0
  207. package/dist/ui/frame.d.ts +17 -0
  208. package/dist/ui/frame.js +27 -0
  209. package/dist/ui/index.d.ts +15 -0
  210. package/dist/ui/index.js +15 -0
  211. package/dist/variables.d.ts +20 -0
  212. package/dist/variables.js +62 -0
  213. package/package.json +88 -4
@@ -0,0 +1,313 @@
1
+ // The connection test of a Shopify app, which its e2e/baukasten.spec.ts (a stub) runs against the
2
+ // built app with the doubles of Shopify and the mail relay. It speaks HTTP as the admin and Shopify
3
+ // would: what App Bridge does in the browser, only a store at Shopify shows. The app's own variables
4
+ // come from the environment of the test run.
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { SHOPIFY_BOUNCE_PATH } from '../shopify/constants.js';
8
+ import { SHOPIFY_TEXTS } from '../shopify/texts.js';
9
+ import { APP_TOML, appToml } from '../shopify/toml.js';
10
+ import { spawnNode, startApp } from './app.js';
11
+ import { createTestDatabase } from './database.js';
12
+ import { startShopify } from './shopify.js';
13
+ import { startSmtp } from './smtp.js';
14
+ const MIGRATE = fileURLToPath(new URL('../bin/migrate.js', import.meta.url));
15
+ /** What the delivery service sets in production, as far as the test sets it itself. */
16
+ const STANDARD = {
17
+ NODE_ENV: 'production',
18
+ MAIL_FROM: 'hello@example.com',
19
+ MAIL_FROM_NAME: 'Connection Test',
20
+ OPERATOR_NAME: 'Connection Test Operator',
21
+ OPERATOR_ADDRESS: 'Street 1, 5400 Baden',
22
+ HOSTING_LOCATION: 'Example Hosting, Nuremberg',
23
+ VERTRIEB_TOKEN: 'v'.repeat(64)
24
+ };
25
+ const SHOP = 'connection-test.myshopify.com';
26
+ const DEVELOPMENT = 'connection-test-dev.myshopify.com';
27
+ /**
28
+ * A path no app has: past the baukasten it gets the app's page for a missing one, whatever the app's
29
+ * own pages would ask of Shopify, which the double cannot answer for every app.
30
+ */
31
+ const NOWHERE = '/baukasten-connection-test';
32
+ /** The app's own variables from the environment of the test run; loud on a required one missing. */
33
+ function ownVariables(env) {
34
+ const given = {};
35
+ const lacking = [];
36
+ for (const [name, schema] of Object.entries(env)) {
37
+ const value = process.env[name];
38
+ if (value !== undefined)
39
+ given[name] = value;
40
+ else if (!schema.safeParse(undefined).success)
41
+ lacking.push(name);
42
+ }
43
+ if (lacking.length > 0) {
44
+ throw new Error(`the connection test takes the app's own variables from the environment of the test run, which lacks ${lacking.join(', ')}: set them in playwright.config.ts`);
45
+ }
46
+ return given;
47
+ }
48
+ /** The scopes the app asks for in its shopify.app.toml, which the double grants. */
49
+ function scopesOfApp() {
50
+ const toml = appToml(readFileSync(APP_TOML, 'utf8'));
51
+ if (toml.kind === 'problems')
52
+ throw new Error(toml.problems.join('; '));
53
+ return toml.toml.scopes;
54
+ }
55
+ /** Registers the connection test of `app` with the test and expect of the app's Playwright. */
56
+ export function shopifyConnectionTest(app, playwright) {
57
+ const { test, expect } = playwright;
58
+ const paid = app.plans.find((plan) => plan.paid);
59
+ if (paid === undefined)
60
+ throw new Error('the app names no paid plan');
61
+ test.describe('the connection of the baukasten', () => {
62
+ test.describe.configure({ mode: 'serial' });
63
+ let database;
64
+ let smtp;
65
+ let shopify;
66
+ let running;
67
+ const undo = [];
68
+ /**
69
+ * A page as the admin opens it, with the shop, embedded=1 and a fresh ID token of `shop`; the
70
+ * address may name another shop (`named`), as a forged one would.
71
+ */
72
+ const inAdmin = (shop, extra = '', path = '/', named = shop) => {
73
+ const host = Buffer.from(`admin.shopify.com/store/${named}`).toString('base64url');
74
+ return `${running.url}${path}?embedded=1&shop=${named}&host=${host}&id_token=${shopify.idToken(shop)}${extra}`;
75
+ };
76
+ const noCookie = (response) => expect(response.headers()['set-cookie'], 'a Shopify app sets no cookie').toBeUndefined();
77
+ test.beforeAll(async () => {
78
+ const own = ownVariables(app.env);
79
+ database = await createTestDatabase({ migrate: false });
80
+ undo.push(() => database.drop());
81
+ smtp = await startSmtp();
82
+ undo.push(() => smtp.stop());
83
+ shopify = await startShopify({ scopes: scopesOfApp() });
84
+ undo.push(() => shopify.stop());
85
+ const migrate = spawnNode([MIGRATE, 'shopify'], {
86
+ ...STANDARD,
87
+ ...own,
88
+ ...shopify.env(),
89
+ DATABASE_URL: database.url,
90
+ PUBLIC_ORIGIN: 'http://127.0.0.1:9',
91
+ PORT: '9',
92
+ SMTP_HOST: smtp.host,
93
+ SMTP_PORT: String(smtp.port)
94
+ });
95
+ expect(await migrate.exited, JSON.stringify(migrate.logs)).toBe(0);
96
+ running = await startApp({
97
+ env: { ...STANDARD, ...own, ...shopify.env(), DATABASE_URL: database.url },
98
+ smtp
99
+ });
100
+ undo.push(() => running.stop());
101
+ shopify.addShop(SHOP);
102
+ shopify.addShop(DEVELOPMENT, { development: true });
103
+ });
104
+ test.afterAll(async () => {
105
+ // Every step, though one before it failed: the test database goes in any case.
106
+ const failed = [];
107
+ for (const step of undo.reverse()) {
108
+ try {
109
+ await step();
110
+ }
111
+ catch (error) {
112
+ failed.push(error);
113
+ }
114
+ }
115
+ if (failed.length > 0) {
116
+ throw new AggregateError(failed, 'cleaning up after the connection test failed');
117
+ }
118
+ });
119
+ test('it is healthy, with its database, the relay and the Partner API', async ({ request }) => {
120
+ expect((await request.get(`${running.url}/health`)).status()).toBe(200);
121
+ const deep = await request.get(`${running.url}/health/deep`);
122
+ expect(await deep.json()).toEqual({
123
+ status: 'ok',
124
+ checks: expect.objectContaining({ database: 'ok', mail: 'ok', shopify: 'ok' })
125
+ });
126
+ });
127
+ test('outside the admin it says where it runs, and no frame may hold it', async ({ request }) => {
128
+ const page = await request.get(`${running.url}/`, { maxRedirects: 0 });
129
+ expect(page.status()).toBe(200);
130
+ expect(page.headers()['content-security-policy']).toContain("frame-ancestors 'none'");
131
+ expect(await page.text()).toContain(SHOPIFY_TEXTS[app.language].outside.privacy);
132
+ noCookie(page);
133
+ const shop = await request.get(`${running.url}/?shop=${SHOP}`, { maxRedirects: 0 });
134
+ expect(shop.status()).toBe(302);
135
+ expect(shop.headers().location).toMatch(/^https:\/\/admin\.shopify\.com\/store\/connection-test\/apps\/[a-z0-9-]+$/);
136
+ });
137
+ test('a page of the admin without an ID token goes to the bounce page and comes back', async ({ request }) => {
138
+ const page = await request.get(`${running.url}/?embedded=1&shop=${SHOP}&host=x`, {
139
+ maxRedirects: 0
140
+ });
141
+ expect(page.status()).toBe(302);
142
+ const bounce = new URL(page.headers().location ?? '', running.url);
143
+ expect(bounce.pathname).toBe(SHOPIFY_BOUNCE_PATH);
144
+ expect(bounce.searchParams.get('shopify-reload')).toContain('embedded=1');
145
+ const app = await request.get(bounce.toString());
146
+ expect(app.status()).toBe(200);
147
+ expect(await app.text()).toContain(`content="${shopify.clientId}"`);
148
+ expect(app.headers()['content-security-policy']).toContain(`frame-ancestors https://${SHOP} https://admin.shopify.com`);
149
+ // App Bridge would open another address with a fresh ID token: no bounce page for it.
150
+ const foreign = new URL(bounce);
151
+ foreign.searchParams.set('shopify-reload', 'https://attacker.example/steal');
152
+ const refused = await request.get(foreign.toString());
153
+ expect(await refused.text()).not.toContain('app-bridge.js');
154
+ expect(refused.headers()['content-security-policy']).toContain("frame-ancestors 'none'");
155
+ });
156
+ test('the first page installs the shop and leads it to the plans of Shopify', async ({ request }) => {
157
+ const page = await request.get(inAdmin(SHOP), { maxRedirects: 0 });
158
+ expect(page.status()).toBe(200);
159
+ expect(await page.text()).toContain('https://admin.shopify.com/store/connection-test/charges/');
160
+ expect(shopify.calls).toContain(`exchange ${SHOP}`);
161
+ noCookie(page);
162
+ });
163
+ test('with a plan the app opens, framed only by the admin of its shop', async ({ request }) => {
164
+ shopify.subscribe(SHOP, [paid.handle]);
165
+ // A path the app has not: the page of the app answers, whatever its own pages ask Shopify.
166
+ const page = await request.get(inAdmin(SHOP, `&plan_handle=${paid.handle}`, NOWHERE), {
167
+ maxRedirects: 0
168
+ });
169
+ expect(page.status()).toBe(404);
170
+ expect(page.headers()['content-type']).toContain('text/html');
171
+ const policy = page.headers()['content-security-policy'] ?? '';
172
+ expect(policy.split(';').map((directive) => directive.trim())).toContain(`frame-ancestors https://${SHOP} https://admin.shopify.com`);
173
+ expect(policy).toContain('https://cdn.shopify.com');
174
+ // The frame belongs to the shop of the ID token, whatever shop the address names.
175
+ const named = await request.get(inAdmin(SHOP, '', NOWHERE, DEVELOPMENT), {
176
+ maxRedirects: 0
177
+ });
178
+ expect(named.headers()['content-security-policy']).toContain(`frame-ancestors https://${SHOP} https://admin.shopify.com`);
179
+ expect(named.headers()['content-security-policy']).not.toContain(DEVELOPMENT);
180
+ expect(page.headers()['x-frame-options']).toBeUndefined();
181
+ expect(page.headers()).toMatchObject({
182
+ 'x-content-type-options': 'nosniff',
183
+ 'referrer-policy': 'strict-origin-when-cross-origin'
184
+ });
185
+ const html = await page.text();
186
+ expect(html).toContain(`<meta name="shopify-api-key" content="${shopify.clientId}" />`);
187
+ // App Bridge is the first script of the page, as the App Store wants.
188
+ expect(html.indexOf('<script')).toBe(html.indexOf('<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js">'));
189
+ noCookie(page);
190
+ });
191
+ test('a request from the admin carries the ID token as a bearer token, from the origin of the app', async ({ request }) => {
192
+ const data = `${running.url}${NOWHERE}/__data.json`;
193
+ const exchanges = shopify.calls.filter((call) => call.startsWith('exchange')).length;
194
+ const valid = await request.get(data, {
195
+ headers: { authorization: `Bearer ${shopify.idToken(SHOP)}` }
196
+ });
197
+ // An installed shop gets in with the tokens it has.
198
+ expect(shopify.calls.filter((call) => call.startsWith('exchange'))).toHaveLength(exchanges);
199
+ // Past the baukasten, to the app, which has no such page.
200
+ expect(valid.status()).toBe(404);
201
+ const stale = await request.get(data, {
202
+ headers: { authorization: `Bearer ${shopify.idToken(SHOP, { seconds: -60 })}` }
203
+ });
204
+ expect(stale.status()).toBe(401);
205
+ expect(stale.headers()['x-shopify-retry-invalid-session-request']).toBe('1');
206
+ const foreign = await request.get(data, {
207
+ headers: { authorization: `Bearer ${shopify.idToken(SHOP, { secret: 'another' })}` }
208
+ });
209
+ expect(foreign.status()).toBe(401);
210
+ // A form posts from the app's own page; from another origin, even with a valid token, not.
211
+ const elsewhere = await request.post(`${running.url}${NOWHERE}`, {
212
+ headers: {
213
+ authorization: `Bearer ${shopify.idToken(SHOP)}`,
214
+ origin: 'https://elsewhere.example'
215
+ },
216
+ maxRedirects: 0
217
+ });
218
+ expect(elsewhere.status()).toBe(403);
219
+ expect(elsewhere.headers()['x-content-type-options']).toBe('nosniff');
220
+ });
221
+ test('webhooks count only with the signature of the app, each once, applied after their answer', async () => {
222
+ expect(await shopify.webhook(running.url, 'customers/redact', SHOP, {}, { signed: false })).toBe(401);
223
+ const request = {
224
+ shop_domain: SHOP,
225
+ customer: { id: 7, email: 'customer@example.com', phone: null },
226
+ orders_requested: [11]
227
+ };
228
+ expect(await shopify.webhook(running.url, 'customers/data_request', SHOP, request)).toBe(200);
229
+ const mail = await smtp.next(`owner@${SHOP}`);
230
+ expect(mail.text).toContain('customer@example.com');
231
+ const redact = {
232
+ shop_domain: SHOP,
233
+ customer: { id: 7, email: 'customer@example.com', phone: null },
234
+ orders_to_redact: [11]
235
+ };
236
+ const id = 'connection-test-redact';
237
+ expect(await shopify.webhook(running.url, 'customers/redact', SHOP, redact, { id })).toBe(200);
238
+ expect(await shopify.webhook(running.url, 'customers/redact', SHOP, redact, { id })).toBe(200);
239
+ // A request about a customer of a shop the app never saw has nothing to answer with.
240
+ const unknown = 'never-opened.myshopify.com';
241
+ expect(await shopify.webhook(running.url, 'customers/data_request', unknown, {
242
+ ...request,
243
+ shop_domain: unknown
244
+ })).toBe(200);
245
+ await expect
246
+ .poll(() => database.query('SELECT count(*)::int AS waiting FROM baukasten.shopify_webhooks WHERE applied_at IS NULL'))
247
+ .toEqual([{ waiting: 0 }]);
248
+ });
249
+ test('the numbers for sales count shops, not development stores', async ({ request }) => {
250
+ expect((await request.get(inAdmin(DEVELOPMENT))).status()).toBe(200);
251
+ expect((await request.get(`${running.url}/vertrieb/zahlen`)).status()).toBe(401);
252
+ const numbers = await request.get(`${running.url}/vertrieb/zahlen`, {
253
+ headers: { authorization: `Bearer ${STANDARD.VERTRIEB_TOKEN}` }
254
+ });
255
+ expect(await numbers.json()).toEqual({
256
+ stand: expect.any(String),
257
+ je_kennung: [
258
+ {
259
+ kennung: 'ohne',
260
+ werte: [
261
+ { name: 'Konten', wert: 1 },
262
+ { name: 'Zahlende', wert: 1 }
263
+ ],
264
+ umsatz: null
265
+ }
266
+ ]
267
+ });
268
+ });
269
+ test('removed, the shop is out; back, it installs again; redacted, it is gone', async ({ request }) => {
270
+ shopify.uninstall(SHOP);
271
+ const removed = { id: 1, myshopify_domain: SHOP };
272
+ expect(await shopify.webhook(running.url, 'app/uninstalled', SHOP, removed)).toBe(200);
273
+ const data = () => request.get(`${running.url}${NOWHERE}/__data.json`, {
274
+ headers: { authorization: `Bearer ${shopify.idToken(SHOP)}` }
275
+ });
276
+ // Back without a plan: the shop installs again, and a request of data finds no plan.
277
+ await expect.poll(async () => (await data()).status()).toBe(402);
278
+ const exchanges = () => shopify.calls.filter((call) => call === `exchange ${SHOP}`).length;
279
+ const before = exchanges();
280
+ expect(await shopify.webhook(running.url, 'shop/redact', SHOP, { shop_domain: SHOP })).toBe(200);
281
+ await expect
282
+ .poll(() => database.query('SELECT count(*)::int AS shops FROM baukasten.shops WHERE domain = $1', [
283
+ SHOP
284
+ ]))
285
+ .toEqual([{ shops: 0 }]);
286
+ // Gone, the shop gets in only with a new token exchange.
287
+ await data();
288
+ expect(exchanges()).toBe(before + 1);
289
+ });
290
+ test('the privacy text names the operator, in the language of the app', async ({ request }) => {
291
+ const page = await request.get(`${running.url}/privacy`);
292
+ expect(page.status()).toBe(200);
293
+ const html = await page.text();
294
+ expect(html).toContain(STANDARD.OPERATOR_NAME);
295
+ expect(html).toContain(`<html lang="${app.language}">`);
296
+ expect(html).not.toMatch(/undefined|null/);
297
+ });
298
+ test('it writes JSON lines only, and no token into its log', () => {
299
+ expect(running.stderr()).toBe('');
300
+ expect(running.logs.filter((line) => 'notJson' in line)).toEqual([]);
301
+ // Every ID token starts with the same header, so any of them in the log would show it.
302
+ const [header = ''] = shopify.idToken(SHOP).split('.');
303
+ expect(header).not.toBe('');
304
+ const log = JSON.stringify(running.logs);
305
+ for (const token of [header, 'shpat_', 'shprt_', shopify.secret]) {
306
+ expect(log).not.toContain(token);
307
+ }
308
+ // The request of a shop that got in names it (SvelteKit gives a request of data the path of
309
+ // its page).
310
+ expect(running.logs).toContainEqual(expect.objectContaining({ msg: 'request', path: NOWHERE, shop: SHOP }));
311
+ });
312
+ });
313
+ }
@@ -0,0 +1,71 @@
1
+ export interface DoubleShop {
2
+ domain: string;
3
+ /** gid://shopify/Shop/… */
4
+ id: string;
5
+ email: string;
6
+ development: boolean;
7
+ /** The tokens Shopify gave the app for it and still takes. */
8
+ accessTokens: Map<string, number>;
9
+ refreshTokens: Set<string>;
10
+ /** The subscription the merchant chose: the handles of its items, and the end of its trial. */
11
+ subscription: {
12
+ handles: string[];
13
+ trialEndsAt: Date | null;
14
+ } | null;
15
+ }
16
+ /** The app's own queries of the Admin API: the data of a query, or errors, as the test says. */
17
+ export type AdminAnswer = (request: {
18
+ shop: string;
19
+ query: string;
20
+ variables: Record<string, unknown>;
21
+ }) => {
22
+ data: unknown;
23
+ } | {
24
+ errors: {
25
+ message: string;
26
+ code?: string;
27
+ }[];
28
+ };
29
+ export interface ShopifyDouble {
30
+ url: string;
31
+ clientId: string;
32
+ secret: string;
33
+ partnerToken: string;
34
+ organization: string;
35
+ /** The app as Shopify knows it, gid://shopify/App/…. */
36
+ appId: string;
37
+ shops: Map<string, DoubleShop>;
38
+ /** What each call asked for, in order, like `exchange example.myshopify.com`. */
39
+ calls: string[];
40
+ /** The variables that send the app to the double; the key of its tokens comes with them. */
41
+ env(): Record<string, string>;
42
+ /** A shop that has the app, or will install it on its first page. */
43
+ addShop(domain: string, options?: {
44
+ development?: boolean;
45
+ email?: string;
46
+ }): DoubleShop;
47
+ /** An ID token for `shop` as App Bridge fetches it, valid for `seconds` (60 by default). */
48
+ idToken(shop: string, options?: {
49
+ seconds?: number;
50
+ secret?: string;
51
+ audience?: string;
52
+ }): string;
53
+ /** The merchant chose a plan; with `trialDays`, it starts as a trial. */
54
+ subscribe(shop: string, handles: string[], options?: {
55
+ trialDays?: number;
56
+ }): void;
57
+ /** The merchant removed the app: its tokens stop working, and its subscription ends. */
58
+ uninstall(shop: string): void;
59
+ /** Answers the app's own queries of the Admin API. */
60
+ answerAdmin(answer: AdminAnswer): void;
61
+ /** Sends a webhook of `topic` to the app, signed unless `signed` is false; the status of its answer. */
62
+ webhook(appUrl: string, topic: string, shop: string, payload: unknown, options?: {
63
+ id?: string;
64
+ signed?: boolean;
65
+ }): Promise<number>;
66
+ stop(): Promise<void>;
67
+ }
68
+ /** Starts the double; it grants the tokens `scopes`, those of the app's shopify.app.toml. */
69
+ export declare function startShopify(options: {
70
+ scopes: string;
71
+ }): Promise<ShopifyDouble>;
@@ -0,0 +1,278 @@
1
+ // A double of Shopify on a free local port, for the tests of a Shopify app: the token endpoint and
2
+ // the Admin API of each shop, and the Partner API. It needs nothing but Node, so no image of an app
3
+ // carries a dependency for it.
4
+ import { createHmac, randomBytes } from 'node:crypto';
5
+ import { createServer } from 'node:http';
6
+ import { SHOPIFY_API_VERSION, SHOPIFY_WEBHOOK_PATH } from '../shopify/constants.js';
7
+ const HOUR_SECONDS = 3_600;
8
+ const REFRESH_SECONDS = 90 * 24 * HOUR_SECONDS;
9
+ function part(value) {
10
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
11
+ }
12
+ function body(request) {
13
+ return new Promise((resolve, reject) => {
14
+ let text = '';
15
+ request.on('data', (chunk) => (text += chunk.toString()));
16
+ request.on('end', () => resolve(text === '' ? null : JSON.parse(text)));
17
+ request.on('error', reject);
18
+ });
19
+ }
20
+ function field(value, name) {
21
+ return typeof value === 'object' && value !== null ? Reflect.get(value, name) : undefined;
22
+ }
23
+ function text(value, name) {
24
+ const found = field(value, name);
25
+ return typeof found === 'string' ? found : null;
26
+ }
27
+ /** The claims of an ID token the double signed, or null. */
28
+ function claims(token, secret) {
29
+ const [header, payload, signature] = token.split('.');
30
+ if (header === undefined || payload === undefined || signature === undefined)
31
+ return null;
32
+ const expected = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
33
+ if (expected !== signature)
34
+ return null;
35
+ const value = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
36
+ return typeof value === 'object' && value !== null
37
+ ? Object.fromEntries(Object.entries(value))
38
+ : null;
39
+ }
40
+ /** Starts the double; it grants the tokens `scopes`, those of the app's shopify.app.toml. */
41
+ export async function startShopify(options) {
42
+ const secret = `shpss_${randomBytes(16).toString('hex')}`;
43
+ const tokenKey = randomBytes(32).toString('hex');
44
+ let counter = 0;
45
+ let admin = () => ({
46
+ errors: [{ message: 'the double knows no answer to this query; answerAdmin gives it one' }]
47
+ });
48
+ const now = () => Math.floor(Date.now() / 1000);
49
+ function tokens(shop) {
50
+ counter += 1;
51
+ const access = `shpat_double${counter}`;
52
+ const refresh = `shprt_double${counter}`;
53
+ shop.accessTokens.set(access, now() + HOUR_SECONDS);
54
+ shop.refreshTokens.add(refresh);
55
+ return {
56
+ access_token: access,
57
+ scope: options.scopes,
58
+ expires_in: HOUR_SECONDS,
59
+ refresh_token: refresh,
60
+ refresh_token_expires_in: REFRESH_SECONDS
61
+ };
62
+ }
63
+ function grant(shop, request) {
64
+ if (text(request, 'client_id') !== double.clientId ||
65
+ text(request, 'client_secret') !== secret) {
66
+ return { status: 401, json: { error: 'invalid_client' } };
67
+ }
68
+ if (shop === undefined)
69
+ return { status: 404, json: { error: 'unknown shop' } };
70
+ const grantType = text(request, 'grant_type');
71
+ if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange') {
72
+ const found = claims(text(request, 'subject_token') ?? '', secret);
73
+ const exp = field(found, 'exp');
74
+ const valid = found !== null &&
75
+ typeof exp === 'number' &&
76
+ exp > now() &&
77
+ text(found, 'dest') === `https://${shop.domain}` &&
78
+ text(request, 'expiring') === '1';
79
+ return valid
80
+ ? { status: 200, json: tokens(shop) }
81
+ : { status: 400, json: { error: 'invalid_subject_token' } };
82
+ }
83
+ if (grantType === 'refresh_token') {
84
+ if (!shop.refreshTokens.delete(text(request, 'refresh_token') ?? '')) {
85
+ return { status: 400, json: { error: 'invalid_grant' } };
86
+ }
87
+ // A refresh ends the tokens before it.
88
+ shop.accessTokens.clear();
89
+ return { status: 200, json: tokens(shop) };
90
+ }
91
+ return { status: 400, json: { error: 'unsupported_grant_type' } };
92
+ }
93
+ function adminQuery(shop, request) {
94
+ const query = text(request, 'query') ?? '';
95
+ const variables = field(request, 'variables');
96
+ if (query.includes('BaukastenShop')) {
97
+ return {
98
+ data: {
99
+ shop: {
100
+ id: shop.id,
101
+ email: shop.email,
102
+ plan: { partnerDevelopment: shop.development }
103
+ },
104
+ currentAppInstallation: { app: { id: double.appId } }
105
+ }
106
+ };
107
+ }
108
+ return admin({
109
+ shop: shop.domain,
110
+ query,
111
+ variables: typeof variables === 'object' && variables !== null
112
+ ? Object.fromEntries(Object.entries(variables))
113
+ : {}
114
+ });
115
+ }
116
+ function partnerQuery(request) {
117
+ const query = text(request, 'query') ?? '';
118
+ if (query.includes('BaukastenCheck')) {
119
+ return { data: { publicApiVersions: [{ handle: SHOPIFY_API_VERSION }] } };
120
+ }
121
+ if (query.includes('BaukastenSubscription')) {
122
+ const variables = field(request, 'variables');
123
+ const shopId = text(variables, 'shopId');
124
+ const appId = text(variables, 'appId');
125
+ const shop = [...double.shops.values()].find((s) => s.id === shopId);
126
+ const subscription = appId === double.appId ? (shop?.subscription ?? null) : null;
127
+ return {
128
+ data: {
129
+ activeSubscription: subscription === null
130
+ ? null
131
+ : {
132
+ trialEndsAt: subscription.trialEndsAt?.toISOString() ?? null,
133
+ items: subscription.handles.map((handle) => ({ handle }))
134
+ }
135
+ }
136
+ };
137
+ }
138
+ return { errors: [{ message: 'the double knows no such query of the Partner API' }] };
139
+ }
140
+ const server = createServer((request, response) => {
141
+ const answer = (status, json) => {
142
+ response.writeHead(status, { 'content-type': 'application/json' });
143
+ response.end(JSON.stringify(json));
144
+ };
145
+ void (async () => {
146
+ const url = new URL(request.url ?? '/', double.url);
147
+ const parsed = await body(request);
148
+ const token = /^\/shops\/([^/]+)\/admin\/oauth\/access_token$/.exec(url.pathname);
149
+ if (token?.[1] !== undefined) {
150
+ double.calls.push(`${text(parsed, 'grant_type') === 'refresh_token' ? 'refresh' : 'exchange'} ${token[1]}`);
151
+ const granted = grant(double.shops.get(token[1]), parsed);
152
+ return answer(granted.status, granted.json);
153
+ }
154
+ const graphql = new RegExp(`^/shops/([^/]+)/admin/api/${SHOPIFY_API_VERSION}/graphql\\.json$`).exec(url.pathname);
155
+ if (graphql?.[1] !== undefined) {
156
+ const shop = double.shops.get(graphql[1]);
157
+ const access = request.headers['x-shopify-access-token'];
158
+ const until = typeof access === 'string' ? shop?.accessTokens.get(access) : undefined;
159
+ if (shop === undefined || until === undefined || until <= now()) {
160
+ return answer(401, { errors: '[API] Invalid API key or access token' });
161
+ }
162
+ double.calls.push(`admin ${shop.domain}`);
163
+ return answer(200, adminQuery(shop, parsed));
164
+ }
165
+ if (url.pathname === `/partners/${double.organization}/api/${SHOPIFY_API_VERSION}/graphql.json`) {
166
+ if (request.headers['x-shopify-access-token'] !== double.partnerToken) {
167
+ return answer(401, { errors: 'Unauthorized' });
168
+ }
169
+ double.calls.push('partner');
170
+ return answer(200, partnerQuery(parsed));
171
+ }
172
+ return answer(404, { errors: 'Not Found' });
173
+ })().catch((error) => answer(500, { errors: String(error) }));
174
+ });
175
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
176
+ const address = server.address();
177
+ if (address === null || typeof address === 'string') {
178
+ throw new Error('the Shopify double has no port');
179
+ }
180
+ const double = {
181
+ url: `http://127.0.0.1:${address.port}`,
182
+ clientId: `double${randomBytes(8).toString('hex')}`,
183
+ secret,
184
+ partnerToken: `prtapi_${randomBytes(16).toString('hex')}`,
185
+ organization: '4242',
186
+ appId: 'gid://shopify/App/4242',
187
+ shops: new Map(),
188
+ calls: [],
189
+ env: () => ({
190
+ SHOPIFY_API_BASE: double.url,
191
+ SHOPIFY_CLIENT_ID: double.clientId,
192
+ SHOPIFY_API_SECRET: secret,
193
+ SHOPIFY_TOKEN_KEY: tokenKey,
194
+ SHOPIFY_PARTNER_TOKEN: double.partnerToken,
195
+ SHOPIFY_PARTNER_ORG: double.organization
196
+ }),
197
+ addShop(domain, options = {}) {
198
+ counter += 1;
199
+ const shop = {
200
+ domain,
201
+ id: `gid://shopify/Shop/${counter}`,
202
+ email: options.email ?? `owner@${domain}`,
203
+ development: options.development ?? false,
204
+ accessTokens: new Map(),
205
+ refreshTokens: new Set(),
206
+ subscription: null
207
+ };
208
+ double.shops.set(domain, shop);
209
+ return shop;
210
+ },
211
+ idToken(shop, options = {}) {
212
+ const issued = now();
213
+ const payload = {
214
+ iss: `https://${shop}/admin`,
215
+ dest: `https://${shop}`,
216
+ aud: options.audience ?? double.clientId,
217
+ sub: '42',
218
+ exp: issued + (options.seconds ?? 60),
219
+ nbf: issued - 1,
220
+ iat: issued - 1,
221
+ jti: randomBytes(16).toString('hex'),
222
+ sid: randomBytes(16).toString('hex')
223
+ };
224
+ const unsigned = `${part({ alg: 'HS256', typ: 'JWT' })}.${part(payload)}`;
225
+ const signature = createHmac('sha256', options.secret ?? secret)
226
+ .update(unsigned)
227
+ .digest('base64url');
228
+ return `${unsigned}.${signature}`;
229
+ },
230
+ subscribe(shop, handles, options = {}) {
231
+ const found = double.shops.get(shop);
232
+ if (found === undefined)
233
+ throw new Error(`the double knows no shop ${shop}`);
234
+ found.subscription = {
235
+ handles,
236
+ trialEndsAt: options.trialDays === undefined
237
+ ? null
238
+ : new Date(Date.now() + options.trialDays * 24 * HOUR_SECONDS * 1000)
239
+ };
240
+ },
241
+ uninstall(shop) {
242
+ const found = double.shops.get(shop);
243
+ if (found === undefined)
244
+ throw new Error(`the double knows no shop ${shop}`);
245
+ found.accessTokens.clear();
246
+ found.refreshTokens.clear();
247
+ found.subscription = null;
248
+ },
249
+ answerAdmin(answer) {
250
+ admin = answer;
251
+ },
252
+ async webhook(appUrl, topic, shop, payload, options = {}) {
253
+ const raw = JSON.stringify(payload);
254
+ const hmac = createHmac('sha256', options.signed === false ? 'not-the-secret' : secret)
255
+ .update(raw)
256
+ .digest('base64');
257
+ const response = await fetch(`${appUrl}${SHOPIFY_WEBHOOK_PATH}`, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'content-type': 'application/json',
261
+ 'x-shopify-topic': topic,
262
+ 'x-shopify-shop-domain': shop,
263
+ 'x-shopify-hmac-sha256': hmac,
264
+ 'x-shopify-webhook-id': options.id ?? randomBytes(16).toString('hex'),
265
+ 'x-shopify-triggered-at': new Date().toISOString(),
266
+ 'x-shopify-api-version': SHOPIFY_API_VERSION
267
+ },
268
+ body: raw
269
+ });
270
+ await response.arrayBuffer();
271
+ return response.status;
272
+ },
273
+ stop: () => new Promise((resolve, reject) => {
274
+ server.close((error) => (error === undefined ? resolve() : reject(error)));
275
+ })
276
+ };
277
+ return double;
278
+ }