@tollwyn/baukasten 0.0.2 → 1.2.3

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,508 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { setTimeout as wait } from 'node:timers/promises';
4
+ import { createDb } from '../server/adapter/db.js';
5
+ import { exitAfterLog, log } from '../server/adapter/log.js';
6
+ import { createMailer } from '../server/adapter/mailer.js';
7
+ import { migrationSources, migrationStatus } from '../server/adapter/migrate.js';
8
+ import { ConfigError } from '../server/config.js';
9
+ import { CHECK_TIMEOUT_MS, CheckFailed, databaseCheck, deepHealth, mailCheck } from '../server/health.js';
10
+ import { createLimits } from '../server/limits.js';
11
+ import { periodic } from '../server/runs.js';
12
+ import { bearerFits, SALES_PATH } from '../server/sales.js';
13
+ import { ShopifyFailed, shopifyApi } from './adapter/shopify.js';
14
+ import { adminApi } from './admin.js';
15
+ import { loadShopifyConfig } from './config.js';
16
+ import { SHOPIFY_ADMIN, SHOPIFY_AUTH_PATH, SHOPIFY_BOUNCE_PATH, SHOPIFY_WEBHOOK_PATH } from './constants.js';
17
+ import { SHOPIFY_SECURITY_HEADERS } from './headers.js';
18
+ import { verifyIdToken } from './id-token.js';
19
+ import { SHOPIFY_PRIVACY_DATE, shopifyPrivacy } from './legal/index.js';
20
+ import { bouncePage, frameAncestors, legalPage, outsidePage, topPage, unavailablePage } from './pages.js';
21
+ import { shopifySalesNumbers } from './sales.js';
22
+ import { shopDomain, storeHandle } from './shop.js';
23
+ import { createShops } from './shops.js';
24
+ import { SHOPIFY_TEXTS } from './texts.js';
25
+ import { APP_TOML } from './toml.js';
26
+ import { createWebhooks } from './webhooks.js';
27
+ const HEALTH = '/health';
28
+ const DEEP = '/health/deep';
29
+ const PRIVACY = '/privacy';
30
+ /** The app's src/app.html, a stub, takes the language and the client ID from these. */
31
+ const LANG = '%baukasten.lang%';
32
+ const CLIENT_ID = '%baukasten.clientId%';
33
+ /** Shopify's library answers an ID token that no longer works this way, and App Bridge tries again. */
34
+ const RETRY = { 'x-shopify-retry-invalid-session-request': '1' };
35
+ /** The parameters of the admin that a page of the app is opened with, which the bounce page drops. */
36
+ const ADMIN_PARAMS = ['id_token'];
37
+ const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
38
+ /** The run of the webhooks beats this often, and touches the database only when it is marked. */
39
+ const WEBHOOK_BEAT_MS = 1_000;
40
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
41
+ function json(body, status, headers = {}) {
42
+ return new Response(JSON.stringify(body), {
43
+ status,
44
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers }
45
+ });
46
+ }
47
+ function laterOf(a, b) {
48
+ return a > b ? a : b;
49
+ }
50
+ export function shopifyBaukasten(app, hooks = {}) {
51
+ const appChecks = hooks.checks ?? {};
52
+ if ('database' in appChecks || 'mail' in appChecks || 'shopify' in appChecks) {
53
+ throw new Error('the checks database, mail and shopify belong to the baukasten');
54
+ }
55
+ // The privacy text promises a merchant what the app keeps of a customer, and its deletion.
56
+ if (app.privacy.data.length > 0 &&
57
+ (hooks.customerData === undefined || hooks.redactCustomer === undefined)) {
58
+ throw new Error('the app keeps personal data for the merchant (privacy.data): it gives the hooks customerData and redactCustomer');
59
+ }
60
+ let state = { kind: 'waiting' };
61
+ /** The shop of each request that got in, by its request, which its loads and actions share. */
62
+ const sessions = new WeakMap();
63
+ function running() {
64
+ switch (state.kind) {
65
+ case 'running':
66
+ return state;
67
+ case 'building':
68
+ throw new Error('the baukasten is used in the build: its pages read the configuration of the running app and cannot be prerendered');
69
+ case 'waiting':
70
+ throw new Error('the baukasten is used before init ran');
71
+ }
72
+ }
73
+ async function stop(applying, context) {
74
+ await applying.stop();
75
+ try {
76
+ await hooks.stop?.(context);
77
+ }
78
+ catch (error) {
79
+ process.exitCode = 1;
80
+ log.error('the stop hook of the app failed', { error });
81
+ }
82
+ await context.db.close();
83
+ log.info('stopped');
84
+ }
85
+ async function start() {
86
+ const root = process.cwd();
87
+ const toml = `${root}/${APP_TOML}`;
88
+ let config;
89
+ try {
90
+ config = loadShopifyConfig(process.env, app, existsSync(toml) ? readFileSync(toml, 'utf8') : undefined);
91
+ }
92
+ catch (error) {
93
+ if (!(error instanceof ConfigError))
94
+ throw error;
95
+ log.error('configuration incomplete, not starting', { problems: error.problems });
96
+ return exitAfterLog(1);
97
+ }
98
+ if (config.missing.length > 0) {
99
+ log.warn('running without what production needs', { missing: config.missing });
100
+ }
101
+ if (config.shopify.clientId === null) {
102
+ log.error(`the app is not linked at Shopify: ${APP_TOML} has no client_id, and no shop gets in`);
103
+ }
104
+ const db = createDb(config.databaseUrl, log);
105
+ const mail = createMailer(config.mail, config.name, log);
106
+ const limits = createLimits({ now: () => Date.now() });
107
+ const context = { config, db, log, mail, limits };
108
+ const api = shopifyApi(config.shopify, { now: () => new Date() });
109
+ const shops = createShops({
110
+ db,
111
+ api,
112
+ key: config.shopify.tokenKey,
113
+ plans: app.plans,
114
+ scopes: config.shopify.scopes,
115
+ log,
116
+ now: () => new Date(),
117
+ nonce: () => randomBytes(12)
118
+ });
119
+ const webhooks = createWebhooks({
120
+ db,
121
+ shops,
122
+ secret: config.shopify.secret,
123
+ hooks,
124
+ mail,
125
+ name: config.name,
126
+ language: config.language,
127
+ log,
128
+ now: () => new Date()
129
+ });
130
+ // Applies the webhooks after their answer. It looks when one came, and when one that failed is
131
+ // due to be tried again: the database sleeps in between.
132
+ let retry = null;
133
+ const applying = periodic({
134
+ name: 'shopify webhooks',
135
+ everyMs: WEBHOOK_BEAT_MS,
136
+ work: async () => {
137
+ const { retryAt, forgetAt } = await webhooks.work();
138
+ const times = [retryAt, forgetAt].flatMap((at) => (at === null ? [] : [at.getTime()]));
139
+ if (retry !== null)
140
+ clearTimeout(retry);
141
+ retry =
142
+ times.length === 0
143
+ ? null
144
+ : setTimeout(() => applying.mark(), Math.max(0, Math.min(...times) - Date.now()));
145
+ retry?.unref();
146
+ }
147
+ });
148
+ const stopRetry = () => {
149
+ if (retry !== null)
150
+ clearTimeout(retry);
151
+ };
152
+ const privacy = shopifyPrivacy({
153
+ language: config.language,
154
+ name: config.name,
155
+ origin: config.publicOrigin,
156
+ operator: config.operator,
157
+ privacy: app.privacy,
158
+ version: laterOf(SHOPIFY_PRIVACY_DATE, app.privacyDate)
159
+ });
160
+ const probe = db.withTimeout(PROBE_TIMEOUT_MS);
161
+ const sources = migrationSources(root, 'shopify');
162
+ const checks = Object.fromEntries(Object.entries(appChecks).map(([name, check]) => [name, () => check(context)]));
163
+ const shopifyHealth = async () => {
164
+ if (config.shopify.clientId === null) {
165
+ throw new CheckFailed(`no client_id in ${APP_TOML}: link the app at Shopify`);
166
+ }
167
+ // With a shop, the token must read its plan, which "Manage apps" grants; before the first
168
+ // shop, it must at least be taken.
169
+ const shop = await shops.anyInstalled();
170
+ try {
171
+ if (shop === null)
172
+ await api.partnerCheck();
173
+ else
174
+ await api.subscription(shop.appId, shop.shopId);
175
+ }
176
+ catch (error) {
177
+ log.error('shopify: the Partner API refused', { error });
178
+ throw new CheckFailed(error instanceof ShopifyFailed ? 'the Partner API refused' : 'unreachable');
179
+ }
180
+ };
181
+ const health = deepHealth({
182
+ checks: {
183
+ database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
184
+ mail: mailCheck(mail, log),
185
+ shopify: shopifyHealth,
186
+ ...checks
187
+ },
188
+ now: () => Date.now(),
189
+ log
190
+ });
191
+ state = { kind: 'running', context, api, shops, webhooks, applying, privacy, health };
192
+ log.info('starting', {
193
+ mode: config.mode,
194
+ origin: config.publicOrigin,
195
+ language: config.language
196
+ });
197
+ applying.start({ db, log });
198
+ // Webhooks that were kept before a restart are applied now.
199
+ applying.mark();
200
+ await hooks.start?.(context);
201
+ process.once('sveltekit:shutdown', () => {
202
+ stopRetry();
203
+ void stop(applying, context);
204
+ });
205
+ }
206
+ const init = (building) => () => {
207
+ if (!building)
208
+ return start();
209
+ state = { kind: 'building' };
210
+ };
211
+ async function health(event) {
212
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
213
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
214
+ }
215
+ // Plain health answers without the database: Neon may sleep, the process still lives.
216
+ if (event.url.pathname === HEALTH)
217
+ return json({ status: 'ok' }, 200);
218
+ const report = await running().health();
219
+ return json(report, report.status === 'ok' ? 200 : 503);
220
+ }
221
+ async function webhook(s, event) {
222
+ if (event.request.method !== 'POST') {
223
+ return new Response(null, { status: 405, headers: { allow: 'POST' } });
224
+ }
225
+ const body = new Uint8Array(await event.request.arrayBuffer());
226
+ const received = await s.webhooks.receive(body, event.request.headers);
227
+ switch (received.kind) {
228
+ case 'unsigned':
229
+ log.warn('shopify: a webhook without the signature of the app');
230
+ return new Response(null, { status: 401 });
231
+ case 'malformed':
232
+ log.warn('shopify: a webhook of an unknown form', { reason: received.reason });
233
+ return new Response(null, { status: 400 });
234
+ case 'queued':
235
+ s.applying.mark();
236
+ return new Response(null, { status: 200 });
237
+ case 'repeated':
238
+ return new Response(null, { status: 200 });
239
+ }
240
+ }
241
+ async function sales(s, event) {
242
+ if (event.request.method !== 'GET') {
243
+ return new Response(null, { status: 405, headers: { allow: 'GET' } });
244
+ }
245
+ if (!bearerFits(event.request.headers.get('authorization'), s.context.config.salesToken)) {
246
+ return json({ error: { code: 'unauthorized', message: 'unauthorized' } }, 401);
247
+ }
248
+ return json(await shopifySalesNumbers(s.context.db, app.plans, new Date()), 200);
249
+ }
250
+ /** The answers of the baukasten itself, or null for a page of the app. */
251
+ function own(s, event) {
252
+ const { config } = s.context;
253
+ const texts = SHOPIFY_TEXTS[config.language];
254
+ switch (event.url.pathname) {
255
+ case SHOPIFY_WEBHOOK_PATH:
256
+ return webhook(s, event);
257
+ case SALES_PATH:
258
+ return sales(s, event);
259
+ case PRIVACY:
260
+ return legalPage({ name: config.name, language: config.language, legal: s.privacy });
261
+ case SHOPIFY_AUTH_PATH:
262
+ return outsidePage({ name: config.name, language: config.language, texts });
263
+ case SHOPIFY_BOUNCE_PATH: {
264
+ const shop = shopDomain(event.url.searchParams.get('shop'));
265
+ // App Bridge opens shopify-reload with a fresh ID token: only an address of this app.
266
+ const reload = event.url.searchParams.get('shopify-reload');
267
+ const ours = reload !== null && URL.canParse(reload) && new URL(reload).origin === config.publicOrigin;
268
+ if (shop === null || !ours || config.shopify.clientId === null) {
269
+ return outsidePage({ name: config.name, language: config.language, texts });
270
+ }
271
+ return bouncePage({ clientId: config.shopify.clientId, shop, language: config.language });
272
+ }
273
+ default:
274
+ return null;
275
+ }
276
+ }
277
+ /** Back to the same page through the bounce page, with a fresh ID token. */
278
+ function bounce(s, url) {
279
+ const params = new URLSearchParams(url.searchParams);
280
+ for (const name of ADMIN_PARAMS)
281
+ params.delete(name);
282
+ const reload = `${s.context.config.publicOrigin}${url.pathname}?${params.toString()}`;
283
+ params.set('shopify-reload', reload);
284
+ return new Response(null, {
285
+ status: 302,
286
+ headers: { location: `${SHOPIFY_BOUNCE_PATH}?${params.toString()}` }
287
+ });
288
+ }
289
+ async function gate(s, event, clientId) {
290
+ const { config } = s.context;
291
+ const url = event.url;
292
+ const bearer = /^Bearer (\S+)$/.exec(event.request.headers.get('authorization') ?? '')?.[1];
293
+ const check = (token) => verifyIdToken(token, { clientId, secret: config.shopify.secret, now: new Date() });
294
+ if (bearer !== undefined) {
295
+ const checked = check(bearer);
296
+ if (checked.kind === 'invalid') {
297
+ log.info('shopify: a request with an ID token that does not hold', {
298
+ reason: checked.reason
299
+ });
300
+ return { kind: 'answer', response: new Response(null, { status: 401, headers: RETRY }) };
301
+ }
302
+ return entered(s, url, checked.shop, bearer, false);
303
+ }
304
+ // A page opened in the admin: Shopify adds the shop, embedded=1 and an ID token to its URL.
305
+ const shop = shopDomain(url.searchParams.get('shop'));
306
+ if (url.searchParams.get('embedded') !== '1') {
307
+ if (shop === null) {
308
+ const texts = SHOPIFY_TEXTS[config.language];
309
+ return {
310
+ kind: 'answer',
311
+ response: outsidePage({ name: config.name, language: config.language, texts })
312
+ };
313
+ }
314
+ // Into the admin of the shop, where the app opens in its frame.
315
+ const location = `${SHOPIFY_ADMIN}/store/${storeHandle(shop)}/apps/${config.shopify.handle}${url.pathname === '/' ? '' : url.pathname}`;
316
+ return {
317
+ kind: 'answer',
318
+ response: new Response(null, { status: 302, headers: { location } })
319
+ };
320
+ }
321
+ const token = url.searchParams.get('id_token');
322
+ if (token === null || token === '')
323
+ return { kind: 'answer', response: bounce(s, url) };
324
+ const checked = check(token);
325
+ if (checked.kind === 'invalid') {
326
+ log.info('shopify: a page with an ID token that does not hold', { reason: checked.reason });
327
+ return { kind: 'answer', response: bounce(s, url) };
328
+ }
329
+ return entered(s, url, checked.shop, token, true);
330
+ }
331
+ async function entered(s, url, shop, idToken, document) {
332
+ const { config } = s.context;
333
+ const entry = await s.shops.enter(shop, idToken);
334
+ switch (entry.kind) {
335
+ case 'in':
336
+ return { kind: 'in', shop, document };
337
+ case 'invalid':
338
+ return {
339
+ kind: 'answer',
340
+ response: document ? bounce(s, url) : new Response(null, { status: 401, headers: RETRY })
341
+ };
342
+ case 'failed':
343
+ log.error('shopify: a shop did not get in', { shop, reason: entry.reason });
344
+ return {
345
+ kind: 'answer',
346
+ response: unavailablePage({
347
+ language: config.language,
348
+ texts: SHOPIFY_TEXTS[config.language],
349
+ shop
350
+ })
351
+ };
352
+ }
353
+ }
354
+ async function planned(s, event, shop, document, clientId) {
355
+ const { config } = s.context;
356
+ let plan;
357
+ try {
358
+ // Back from the plan page of Shopify, the page asks the plan again at once; its requests,
359
+ // which may carry the parameter as well, take what it found.
360
+ plan = await s.shops.plan(shop, document && event.url.searchParams.has('plan_handle'));
361
+ }
362
+ catch (error) {
363
+ log.error('shopify: the plan of a shop could not be read', { shop, error });
364
+ return {
365
+ kind: 'answer',
366
+ response: unavailablePage({
367
+ language: config.language,
368
+ texts: SHOPIFY_TEXTS[config.language],
369
+ shop
370
+ })
371
+ };
372
+ }
373
+ if (plan.kind === 'active')
374
+ return { kind: 'plan', plan };
375
+ if (!document)
376
+ return { kind: 'answer', response: new Response(null, { status: 402 }) };
377
+ const pricing = `${SHOPIFY_ADMIN}/store/${storeHandle(shop)}/charges/${config.shopify.handle}/pricing_plans`;
378
+ return {
379
+ kind: 'answer',
380
+ response: topPage({
381
+ clientId,
382
+ shop,
383
+ url: pricing,
384
+ language: config.language,
385
+ nonce: randomBytes(16).toString('base64')
386
+ })
387
+ };
388
+ }
389
+ /**
390
+ * A page without its content security policy does not go out: the policy comes from
391
+ * SHOPIFY_SVELTEKIT_OPTIONS in the configuration of the app; with it, only the admin of the shop
392
+ * may frame the page.
393
+ */
394
+ let policyMissing = false;
395
+ function framed(response, shop, path) {
396
+ const page = response.headers.get('content-type')?.startsWith('text/html') === true;
397
+ if (!page)
398
+ return response;
399
+ const policy = response.headers.get('content-security-policy');
400
+ if (policy === null) {
401
+ if (response.status >= 500)
402
+ return response;
403
+ if (!policyMissing) {
404
+ policyMissing = true;
405
+ log.error('a page without its content security policy: SHOPIFY_SVELTEKIT_OPTIONS of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the app answers HTML of its own; every such answer is a 500', { path });
406
+ }
407
+ return new Response('Internal Error', {
408
+ status: 500,
409
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
410
+ });
411
+ }
412
+ response.headers.set('content-security-policy', `${policy}; ${frameAncestors(shop)}`);
413
+ return response;
414
+ }
415
+ async function respondTo(event, resolve) {
416
+ const path = event.url.pathname;
417
+ if (path === HEALTH || path === DEEP)
418
+ return health(event);
419
+ const s = running();
420
+ const { config } = s.context;
421
+ const answer = own(s, event);
422
+ if (answer !== null)
423
+ return answer;
424
+ const clientId = config.shopify.clientId;
425
+ if (clientId === null) {
426
+ return unavailablePage({
427
+ language: config.language,
428
+ texts: SHOPIFY_TEXTS[config.language],
429
+ shop: null
430
+ });
431
+ }
432
+ // Unsafe requests of the app come from its own pages in the frame; another origin is refused.
433
+ if (!SAFE_METHODS.includes(event.request.method) &&
434
+ event.request.headers.get('origin') !== config.publicOrigin) {
435
+ log.warn('request refused: from another origin', { path });
436
+ return new Response('Forbidden', {
437
+ status: 403,
438
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
439
+ });
440
+ }
441
+ const gated = await gate(s, event, clientId);
442
+ if (gated.kind === 'answer')
443
+ return gated.response;
444
+ const plan = await planned(s, event, gated.shop, gated.document, clientId);
445
+ if (plan.kind === 'answer')
446
+ return plan.response;
447
+ sessions.set(event.request, { domain: gated.shop, plan: plan.plan });
448
+ const response = await resolve(event, {
449
+ transformPageChunk: ({ html }) => html.replace(LANG, config.language).replace(CLIENT_ID, clientId)
450
+ });
451
+ return framed(response, gated.shop, path);
452
+ }
453
+ const handle = async ({ event, resolve }) => {
454
+ // Prerendering in the build is no request of anyone.
455
+ if (state.kind === 'building')
456
+ return resolve(event);
457
+ const started = performance.now();
458
+ let status = 500;
459
+ try {
460
+ const response = await respondTo(event, resolve);
461
+ for (const [name, value] of Object.entries(SHOPIFY_SECURITY_HEADERS)) {
462
+ response.headers.set(name, value);
463
+ }
464
+ status = response.status;
465
+ return response;
466
+ }
467
+ finally {
468
+ // The container polls /health; it is logged only at debug. The path goes without its query,
469
+ // which carries the ID token; the shop of a request that got in goes with it, the privacy
470
+ // text says so, and a request that did not get in has none.
471
+ (event.url.pathname === HEALTH ? log.debug : log.info)('request', {
472
+ method: event.request.method,
473
+ path: event.url.pathname,
474
+ status,
475
+ ms: Math.round(performance.now() - started),
476
+ shop: sessions.get(event.request)?.domain
477
+ });
478
+ }
479
+ };
480
+ const handleError = ({ error, event, status, message }) => {
481
+ if (status === 404)
482
+ return { message };
483
+ (status >= 500 ? log.error : log.warn)('request failed', {
484
+ method: event.request.method,
485
+ path: event.url.pathname,
486
+ status,
487
+ error
488
+ });
489
+ return { message: status >= 500 ? 'Internal Error' : message };
490
+ };
491
+ return {
492
+ init,
493
+ handle,
494
+ handleError,
495
+ context: () => running().context,
496
+ shop: (event) => {
497
+ const session = sessions.get(event.request);
498
+ if (session === undefined) {
499
+ throw new Error('no shop got in with this request: kit.shop serves the pages of the app');
500
+ }
501
+ return session;
502
+ },
503
+ admin: (shop) => {
504
+ const s = running();
505
+ return adminApi(shop, { api: s.api, shops: s.shops, log, wait: (ms) => wait(ms) });
506
+ }
507
+ };
508
+ }
@@ -0,0 +1,12 @@
1
+ export type IdTokenCheck = {
2
+ kind: 'valid';
3
+ shop: string;
4
+ } | {
5
+ kind: 'invalid';
6
+ reason: 'form' | 'algorithm' | 'signature' | 'expired' | 'not yet valid' | 'audience' | 'destination' | 'issuer';
7
+ };
8
+ export declare function verifyIdToken(token: string, app: {
9
+ clientId: string;
10
+ secret: string;
11
+ now: Date;
12
+ }): IdTokenCheck;
@@ -0,0 +1,66 @@
1
+ // The ID token of Shopify (formerly the session token): a JWT that App Bridge fetches in the admin and
2
+ // the app receives with a request, as `id_token` in the URL of a page or as a bearer token. HS256 with
3
+ // the client secret of the app; it lives a minute. The checks are those of Shopify's own library:
4
+ // signature, time with ten seconds for clocks that differ, the app as audience, and issuer and
5
+ // destination on the same shop. The reason names what failed and never the token.
6
+ import { createHmac, timingSafeEqual } from 'node:crypto';
7
+ import { z } from 'zod';
8
+ import { shopDomain } from './shop.js';
9
+ /** Clocks of Shopify and of the server may differ by this much. */
10
+ const TOLERANCE_SECONDS = 10;
11
+ const HEADER = z.object({ alg: z.string() });
12
+ const CLAIMS = z.object({
13
+ iss: z.url(),
14
+ dest: z.url(),
15
+ aud: z.string(),
16
+ exp: z.number(),
17
+ nbf: z.number()
18
+ });
19
+ function decoded(part) {
20
+ try {
21
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ /** Whether `signature` is the HS256 of `unsigned`, compared in constant time. */
28
+ function signed(unsigned, signature, secret) {
29
+ const expected = createHmac('sha256', secret).update(unsigned).digest();
30
+ const given = Buffer.from(signature, 'base64url');
31
+ return given.length === expected.length && timingSafeEqual(given, expected);
32
+ }
33
+ export function verifyIdToken(token, app) {
34
+ const parts = token.split('.');
35
+ if (parts.length !== 3)
36
+ return { kind: 'invalid', reason: 'form' };
37
+ const [header = '', payload = '', signature = ''] = parts;
38
+ const head = HEADER.safeParse(decoded(header));
39
+ if (!head.success)
40
+ return { kind: 'invalid', reason: 'form' };
41
+ if (head.data.alg !== 'HS256')
42
+ return { kind: 'invalid', reason: 'algorithm' };
43
+ if (!signed(`${header}.${payload}`, signature, app.secret)) {
44
+ return { kind: 'invalid', reason: 'signature' };
45
+ }
46
+ const claims = CLAIMS.safeParse(decoded(payload));
47
+ if (!claims.success)
48
+ return { kind: 'invalid', reason: 'form' };
49
+ const { iss, dest, aud, exp, nbf } = claims.data;
50
+ const now = app.now.getTime() / 1000;
51
+ if (exp + TOLERANCE_SECONDS <= now)
52
+ return { kind: 'invalid', reason: 'expired' };
53
+ if (nbf - TOLERANCE_SECONDS > now)
54
+ return { kind: 'invalid', reason: 'not yet valid' };
55
+ if (aud !== app.clientId)
56
+ return { kind: 'invalid', reason: 'audience' };
57
+ const destination = new URL(dest);
58
+ const shop = shopDomain(destination.protocol === 'https:' ? destination.hostname : null);
59
+ if (shop === null)
60
+ return { kind: 'invalid', reason: 'destination' };
61
+ const issuer = new URL(iss);
62
+ if (issuer.protocol !== 'https:' || issuer.hostname !== shop) {
63
+ return { kind: 'invalid', reason: 'issuer' };
64
+ }
65
+ return { kind: 'valid', shop };
66
+ }
@@ -0,0 +1,19 @@
1
+ import { type NodeHandler } from '../server/start.js';
2
+ export { log } from '../server/adapter/log.js';
3
+ export { CheckFailed } from '../server/health.js';
4
+ export { LIMITS, type Limits, type Rule } from '../server/limits.js';
5
+ export type { Log } from '../server/log.js';
6
+ export { MailFailed } from '../server/mail.js';
7
+ export type { Db, Mail, Mailer, Queryable } from '../server/ports.js';
8
+ export { periodic, type Run, type RunOptions } from '../server/runs.js';
9
+ export type { NodeHandler } from '../server/start.js';
10
+ export { AdminRefused, ShopGone, type AdminApi } from './admin.js';
11
+ export type { ShopifyConfig } from './config.js';
12
+ export { SHOPIFY_API_VERSION, SHOPIFY_WEBHOOK_PATH } from './constants.js';
13
+ export { shopifyBaukasten, type ShopifyContext, type ShopifyHooks, type ShopifyKit, type ShopSession } from './hook.js';
14
+ export type { Plan } from './shops.js';
15
+ export type { CustomerRequest, Delivery } from './webhooks.js';
16
+ /** The server of a Shopify app, called by its stub server.js with the handler of adapter-node. */
17
+ export declare function start(load: () => Promise<{
18
+ handler: NodeHandler;
19
+ }>): Promise<void>;
@@ -0,0 +1,15 @@
1
+ // The server entry of a Shopify app; nothing here may reach the browser.
2
+ import { serve } from '../server/start.js';
3
+ import { SHOPIFY_SECURITY_HEADERS } from './headers.js';
4
+ export { log } from '../server/adapter/log.js';
5
+ export { CheckFailed } from '../server/health.js';
6
+ export { LIMITS } from '../server/limits.js';
7
+ export { MailFailed } from '../server/mail.js';
8
+ export { periodic } from '../server/runs.js';
9
+ export { AdminRefused, ShopGone } from './admin.js';
10
+ export { SHOPIFY_API_VERSION, SHOPIFY_WEBHOOK_PATH } from './constants.js';
11
+ export { shopifyBaukasten } from './hook.js';
12
+ /** The server of a Shopify app, called by its stub server.js with the handler of adapter-node. */
13
+ export function start(load) {
14
+ return serve(load, SHOPIFY_SECURITY_HEADERS);
15
+ }
@@ -0,0 +1,3 @@
1
+ import type { LegalData } from '../../ui/data.js';
2
+ import type { ShopifyLegalFacts } from './index.js';
3
+ export declare function de(f: ShopifyLegalFacts): LegalData;