@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,49 @@
1
+ const who = (customer) => [customer.id, customer.email, customer.phone].filter((part) => part !== null).join(', ');
2
+ export const SHOPIFY_TEXTS = {
3
+ en: {
4
+ outside: {
5
+ title: (name) => name,
6
+ text: (name) => `${name} runs in the admin of your Shopify store. Open it there, under Apps.`,
7
+ privacy: 'Privacy policy'
8
+ },
9
+ unavailable: {
10
+ title: 'Not available right now',
11
+ text: 'Shopify did not answer as it should. Please try again in a moment.'
12
+ },
13
+ dataRequest: {
14
+ subject: (name) => `${name}: a customer asks for their data`,
15
+ text: ({ name, shop, customer, data }) => [
16
+ `Shopify passed on a request of a customer of your store ${shop} (${who(customer)}) for the personal data ${name} keeps about them.`,
17
+ '',
18
+ data === null
19
+ ? `${name} keeps no personal data about this customer.`
20
+ : `${name} keeps these data about this customer:\n\n${data}`,
21
+ '',
22
+ 'Please pass this on to your customer with your answer to their request.'
23
+ ].join('\n')
24
+ }
25
+ },
26
+ de: {
27
+ outside: {
28
+ title: (name) => name,
29
+ text: (name) => `${name} läuft in der Verwaltung Ihres Shopify-Shops. Öffnen Sie es dort, unter Apps.`,
30
+ privacy: 'Datenschutz'
31
+ },
32
+ unavailable: {
33
+ title: 'Gerade nicht verfügbar',
34
+ text: 'Shopify hat nicht geantwortet, wie es sollte. Bitte versuchen Sie es gleich noch einmal.'
35
+ },
36
+ dataRequest: {
37
+ subject: (name) => `${name}: Ein Kunde fragt nach seinen Daten`,
38
+ text: ({ name, shop, customer, data }) => [
39
+ `Shopify hat die Anfrage eines Kunden Ihres Shops ${shop} (${who(customer)}) nach den Personendaten weitergegeben, die ${name} über ihn hält.`,
40
+ '',
41
+ data === null
42
+ ? `${name} hält keine Personendaten über diesen Kunden.`
43
+ : `${name} hält diese Daten über diesen Kunden:\n\n${data}`,
44
+ '',
45
+ 'Bitte geben Sie das mit Ihrer Antwort auf seine Anfrage an Ihren Kunden weiter.'
46
+ ].join('\n')
47
+ }
48
+ }
49
+ };
@@ -0,0 +1,22 @@
1
+ export declare const APP_TOML = "shopify.app.toml";
2
+ /** The domain under which the fleet runs every product, <subdomain>.tollwyn.com. */
3
+ export declare const FLEET_DOMAIN = "tollwyn.com";
4
+ /** What the server reads of the configuration. */
5
+ export interface AppToml {
6
+ /** The client ID; null until the app is linked at Shopify. */
7
+ clientId: string | null;
8
+ /** The handle of the app in the admin, admin.shopify.com/store/<shop>/apps/<handle>. */
9
+ handle: string;
10
+ /** The scopes the app asks for: tokens granted fewer are exchanged anew. */
11
+ scopes: string;
12
+ }
13
+ /** The client ID, handle and scopes of the app, or what keeps the server from reading them. */
14
+ export declare function appToml(text: string): {
15
+ kind: 'ok';
16
+ toml: AppToml;
17
+ } | {
18
+ kind: 'problems';
19
+ problems: string[];
20
+ };
21
+ /** What keeps the configuration from fitting the baukasten and the subdomain of the app. */
22
+ export declare function appTomlProblems(text: string, subdomain: string): string[];
@@ -0,0 +1,125 @@
1
+ import { parse } from 'smol-toml';
2
+ import { z } from 'zod';
3
+ import { COMPLIANCE_TOPICS, SHOPIFY_API_VERSION, SHOPIFY_AUTH_PATH, SHOPIFY_WEBHOOK_PATH } from './constants.js';
4
+ export const APP_TOML = 'shopify.app.toml';
5
+ /** The domain under which the fleet runs every product, <subdomain>.tollwyn.com. */
6
+ export const FLEET_DOMAIN = 'tollwyn.com';
7
+ const HANDLE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const RUNTIME = z.looseObject({
9
+ client_id: z.string('must be the client ID of the app, or empty until it is linked'),
10
+ handle: z
11
+ .string('must be the handle of the app, like invoice-sync')
12
+ .regex(HANDLE, 'must be the handle of the app, like invoice-sync'),
13
+ access_scopes: z.looseObject({ scopes: z.string('must list the scopes of the app') }, 'must list the scopes of the app')
14
+ });
15
+ const SUBSCRIPTION = z.looseObject({
16
+ topics: z.array(z.string()).optional(),
17
+ compliance_topics: z.array(z.string()).optional(),
18
+ uri: z.string('must name where the webhooks go')
19
+ });
20
+ const FULL = z.looseObject({
21
+ name: z.string('must name the app').trim().min(1, 'must name the app'),
22
+ embedded: z.literal(true, 'must be true: the app runs in the admin'),
23
+ access_scopes: z.looseObject({
24
+ use_legacy_install_flow: z
25
+ .literal(false, 'must be false or left out: Shopify installs the app (managed installation)')
26
+ .optional()
27
+ }),
28
+ auth: z.looseObject({
29
+ redirect_urls: z.array(z.string(), 'must list the redirect URL of the baukasten')
30
+ }),
31
+ webhooks: z.looseObject({
32
+ api_version: z.literal(SHOPIFY_API_VERSION, `must be ${SHOPIFY_API_VERSION}, the version of the baukasten`),
33
+ subscriptions: z.array(SUBSCRIPTION).min(1, 'must subscribe the webhooks of the baukasten')
34
+ }),
35
+ build: z
36
+ .looseObject({
37
+ automatically_update_urls_on_dev: z
38
+ .literal(false, 'must be false or left out: the URLs of the app are those of its subdomain')
39
+ .optional()
40
+ })
41
+ .optional()
42
+ });
43
+ /** Tables the baukasten does not serve: their requests would reach the app unchecked. */
44
+ const UNSERVED = {
45
+ app_proxy: 'an app proxy',
46
+ events: 'the events of Shopify',
47
+ customer_authentication: 'customer accounts'
48
+ };
49
+ function read(text) {
50
+ try {
51
+ return { kind: 'ok', value: parse(text) };
52
+ }
53
+ catch (error) {
54
+ return { kind: 'broken', reason: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ }
57
+ function issues(error) {
58
+ return error.issues.map((issue) => `${APP_TOML}: ${issue.path.join('.')} ${issue.message}`);
59
+ }
60
+ /** The client ID, handle and scopes of the app, or what keeps the server from reading them. */
61
+ export function appToml(text) {
62
+ const parsed = read(text);
63
+ if (parsed.kind === 'broken') {
64
+ return { kind: 'problems', problems: [`${APP_TOML} is no TOML: ${parsed.reason}`] };
65
+ }
66
+ const toml = RUNTIME.safeParse(parsed.value);
67
+ if (!toml.success)
68
+ return { kind: 'problems', problems: issues(toml.error) };
69
+ const clientId = toml.data.client_id.trim();
70
+ return {
71
+ kind: 'ok',
72
+ toml: {
73
+ clientId: clientId === '' ? null : clientId,
74
+ handle: toml.data.handle,
75
+ scopes: toml.data.access_scopes.scopes
76
+ }
77
+ };
78
+ }
79
+ /** What keeps the configuration from fitting the baukasten and the subdomain of the app. */
80
+ export function appTomlProblems(text, subdomain) {
81
+ const parsed = read(text);
82
+ if (parsed.kind === 'broken')
83
+ return [`${APP_TOML} is no TOML: ${parsed.reason}`];
84
+ const runtime = RUNTIME.safeParse(parsed.value);
85
+ const full = FULL.safeParse(parsed.value);
86
+ const problems = [
87
+ ...(runtime.success ? [] : issues(runtime.error)),
88
+ ...(full.success ? [] : issues(full.error))
89
+ ];
90
+ const tables = z.looseObject({}).parse(parsed.value);
91
+ for (const [table, what] of Object.entries(UNSERVED)) {
92
+ if (table in tables) {
93
+ problems.push(`${APP_TOML}: [${table}] is ${what}, which this version does not serve`);
94
+ }
95
+ }
96
+ const origin = `https://${subdomain}.${FLEET_DOMAIN}`;
97
+ const url = z.looseObject({ application_url: z.unknown() }).parse(parsed.value).application_url;
98
+ if (url !== origin)
99
+ problems.push(`${APP_TOML}: application_url must be ${origin}`);
100
+ if (!full.success)
101
+ return problems;
102
+ const redirects = full.data.auth.redirect_urls;
103
+ const redirect = `${origin}${SHOPIFY_AUTH_PATH}`;
104
+ if (redirects.length !== 1 || redirects[0] !== redirect) {
105
+ problems.push(`${APP_TOML}: auth.redirect_urls must be ["${redirect}"]`);
106
+ }
107
+ const subscriptions = full.data.webhooks.subscriptions;
108
+ for (const subscription of subscriptions) {
109
+ if (subscription.uri !== SHOPIFY_WEBHOOK_PATH) {
110
+ problems.push(`${APP_TOML}: every webhook goes to ${SHOPIFY_WEBHOOK_PATH}, where the baukasten checks it, not to ${subscription.uri}`);
111
+ }
112
+ }
113
+ const topics = new Set(subscriptions.flatMap((s) => s.topics ?? []));
114
+ const compliance = new Set(subscriptions.flatMap((s) => s.compliance_topics ?? []));
115
+ if (!topics.has('app/uninstalled')) {
116
+ problems.push(`${APP_TOML}: the webhooks lack the topic app/uninstalled`);
117
+ }
118
+ for (const topic of COMPLIANCE_TOPICS.filter((t) => !compliance.has(t))) {
119
+ problems.push(`${APP_TOML}: the webhooks lack the compliance topic ${topic}`);
120
+ }
121
+ for (const topic of [...compliance].filter((t) => !COMPLIANCE_TOPICS.includes(t))) {
122
+ problems.push(`${APP_TOML}: ${topic} is no compliance topic`);
123
+ }
124
+ return problems;
125
+ }
@@ -0,0 +1,65 @@
1
+ import type { Language } from '../product.js';
2
+ import type { Log } from '../server/log.js';
3
+ import type { Db, Mailer, Queryable } from '../server/ports.js';
4
+ import type { Shops } from './shops.js';
5
+ /** A request about a customer of a shop, from customers/redact or customers/data_request. */
6
+ export interface CustomerRequest {
7
+ shop: string;
8
+ customer: {
9
+ id: string;
10
+ email: string | null;
11
+ phone: string | null;
12
+ };
13
+ /** The orders it concerns, by their ids at Shopify. */
14
+ orders: string[];
15
+ }
16
+ /** A webhook of a topic of the app's own, as Shopify sent it. */
17
+ export interface Delivery {
18
+ topic: string;
19
+ shop: string;
20
+ payload: unknown;
21
+ }
22
+ /** What the baukasten calls in the app for its webhooks. */
23
+ export interface WebhookHooks {
24
+ /** customers/redact: deletes what the app keeps of the customer, in the transaction of the webhook. */
25
+ redactCustomer?: (tx: Queryable, request: CustomerRequest) => Promise<void>;
26
+ /**
27
+ * customers/data_request: what the app keeps of the customer, as text for the merchant, which the
28
+ * baukasten mails to the shop; null when it keeps nothing of them.
29
+ */
30
+ customerData?: (db: Queryable, request: CustomerRequest) => Promise<string | null>;
31
+ /** Every other topic of shopify.app.toml, once per webhook, in its transaction. */
32
+ webhook?: (tx: Queryable, delivery: Delivery) => Promise<void>;
33
+ }
34
+ export type Received = {
35
+ kind: 'unsigned';
36
+ } | {
37
+ kind: 'malformed';
38
+ reason: string;
39
+ } | {
40
+ kind: 'queued';
41
+ } | {
42
+ kind: 'repeated';
43
+ };
44
+ /** An applied webhook is remembered this long, so that Shopify sending it again applies it once. */
45
+ export declare const KEPT_MS: number;
46
+ /** After a failure a webhook waits this long before its next try; after the last, it gives up. */
47
+ export declare const RETRY_AFTER_MS: number[];
48
+ export declare function createWebhooks(deps: {
49
+ db: Db;
50
+ shops: Shops;
51
+ secret: string;
52
+ hooks: WebhookHooks;
53
+ mail: Mailer;
54
+ /** The name of the app, in the mail of a data request. */
55
+ name: string;
56
+ language: Language;
57
+ log: Log;
58
+ now: () => Date;
59
+ }): {
60
+ receive: (body: Uint8Array, headers: Headers) => Promise<Received>;
61
+ work: () => Promise<{
62
+ retryAt: Date | null;
63
+ forgetAt: Date | null;
64
+ }>;
65
+ };
@@ -0,0 +1,220 @@
1
+ // A webhook without the signature of the client secret gets 401 before anything is read, as the
2
+ // review of the App Store tests. A signed one is kept and answered at once, since Shopify waits
3
+ // five seconds, and a run applies it after; its shop and payload, which may hold personal data, go
4
+ // once it is applied.
5
+ import { z } from 'zod';
6
+ import { webhookSigned } from './hmac.js';
7
+ import { shopDomain } from './shop.js';
8
+ import { SHOPIFY_TEXTS } from './texts.js';
9
+ /** An applied webhook is remembered this long, so that Shopify sending it again applies it once. */
10
+ export const KEPT_MS = 7 * 24 * 3_600_000;
11
+ /** After a failure a webhook waits this long before its next try; after the last, it gives up. */
12
+ export const RETRY_AFTER_MS = [
13
+ 60_000,
14
+ 5 * 60_000,
15
+ 30 * 60_000,
16
+ 2 * 3_600_000,
17
+ 6 * 3_600_000,
18
+ 12 * 3_600_000
19
+ ];
20
+ const ID = z.union([z.number().int(), z.string()]).transform(String);
21
+ const CUSTOMER_REQUEST = z.object({
22
+ customer: z.object({
23
+ id: ID,
24
+ email: z
25
+ .string()
26
+ .nullish()
27
+ .transform((v) => v ?? null),
28
+ phone: z
29
+ .string()
30
+ .nullish()
31
+ .transform((v) => v ?? null)
32
+ }),
33
+ orders_to_redact: z.array(ID).optional(),
34
+ orders_requested: z.array(ID).optional()
35
+ });
36
+ /**
37
+ * Where a payload of a topic of the baukasten names its shop. The signature covers the body, not the
38
+ * headers: the shop the body names counts, and a body without the field is refused, so that a signed
39
+ * body cannot be sent for another shop. Only a field that names no shop, like the placeholder
40
+ * `{shop}.myshopify.com` of the samples of Shopify, leaves the shop of the header.
41
+ */
42
+ const SHOP_IN_PAYLOAD = {
43
+ 'app/uninstalled': 'myshopify_domain',
44
+ 'shop/redact': 'shop_domain',
45
+ 'customers/redact': 'shop_domain',
46
+ 'customers/data_request': 'shop_domain'
47
+ };
48
+ const TRIGGERED_AT = z.iso.datetime({ offset: true });
49
+ export function createWebhooks(deps) {
50
+ const { db, shops, hooks, log, now } = deps;
51
+ function customerRequest(shop, payload) {
52
+ const { customer, orders_to_redact: redact, orders_requested: requested } = CUSTOMER_REQUEST.parse(payload);
53
+ // customers/redact names the orders to redact, customers/data_request those requested.
54
+ return { shop, customer, orders: redact ?? requested ?? [] };
55
+ }
56
+ /**
57
+ * Mails the merchant what the app keeps of the customer: Shopify wants the answer with the shop. A
58
+ * shop the app does not know (never opened, or deleted) has nothing in the app to answer with.
59
+ */
60
+ async function answerRequest(tx, request) {
61
+ const data = (await hooks.customerData?.(tx, request)) ?? null;
62
+ const to = await shops.email(tx, request.shop);
63
+ if (to === null) {
64
+ if (data !== null) {
65
+ throw new Error('the app keeps data of a customer of a shop it does not know');
66
+ }
67
+ log.info('shopify: a request about a customer of a shop the app does not know', {
68
+ shop: request.shop
69
+ });
70
+ return;
71
+ }
72
+ const texts = SHOPIFY_TEXTS[deps.language].dataRequest;
73
+ await deps.mail.send({
74
+ to,
75
+ subject: texts.subject(deps.name),
76
+ text: texts.text({ name: deps.name, shop: request.shop, customer: request.customer, data })
77
+ });
78
+ }
79
+ async function apply(tx, webhook) {
80
+ const { topic, shop, payload } = webhook;
81
+ switch (topic) {
82
+ case 'app/uninstalled':
83
+ if (await shops.uninstall(tx, shop, webhook.triggered_at)) {
84
+ log.info('shopify: a shop removed the app', { shop });
85
+ }
86
+ else {
87
+ log.info('shopify: an app/uninstalled that ends no installation: the shop is unknown, removed already, or added the app again after it', { shop });
88
+ }
89
+ return;
90
+ case 'shop/redact':
91
+ await shops.redact(tx, shop);
92
+ // Every other webhook of the shop goes with it, waiting or given up; this one empties as
93
+ // it is applied.
94
+ await tx.query('DELETE FROM baukasten.shopify_webhooks WHERE shop = $1 AND id <> $2', [
95
+ shop,
96
+ webhook.id
97
+ ]);
98
+ log.info('shopify: the data of a shop are deleted', { shop });
99
+ return;
100
+ case 'customers/redact':
101
+ await hooks.redactCustomer?.(tx, customerRequest(shop, payload));
102
+ return;
103
+ case 'customers/data_request':
104
+ await answerRequest(tx, customerRequest(shop, payload));
105
+ return;
106
+ default:
107
+ if (hooks.webhook === undefined) {
108
+ log.error('shopify: a webhook of a topic the app takes no hook for', { topic });
109
+ return;
110
+ }
111
+ await hooks.webhook(tx, { topic, shop, payload });
112
+ }
113
+ }
114
+ async function receive(body, headers) {
115
+ if (!webhookSigned(body, headers.get('x-shopify-hmac-sha256'), deps.secret)) {
116
+ return { kind: 'unsigned' };
117
+ }
118
+ const topic = headers.get('x-shopify-topic');
119
+ const id = headers.get('x-shopify-webhook-id');
120
+ let payload;
121
+ try {
122
+ payload = JSON.parse(Buffer.from(body).toString('utf8'));
123
+ }
124
+ catch {
125
+ return { kind: 'malformed', reason: 'no JSON' };
126
+ }
127
+ const field = topic === null ? undefined : SHOP_IN_PAYLOAD[topic];
128
+ const named = field !== undefined && typeof payload === 'object' && payload !== null
129
+ ? Reflect.get(payload, field)
130
+ : undefined;
131
+ if (field !== undefined && typeof named !== 'string') {
132
+ return { kind: 'malformed', reason: `without ${field}` };
133
+ }
134
+ const shop = shopDomain(typeof named === 'string' ? named : null) ??
135
+ shopDomain(headers.get('x-shopify-shop-domain'));
136
+ if (topic === null || shop === null || id === null || id === '') {
137
+ return { kind: 'malformed', reason: 'without topic, shop or id' };
138
+ }
139
+ // When the event happened: Shopify tries a webhook again for hours, and a late app/uninstalled
140
+ // must not end an installation that came after it. Without the header, from when it came.
141
+ const triggered = headers.get('x-shopify-triggered-at');
142
+ if (triggered !== null && !TRIGGERED_AT.safeParse(triggered).success) {
143
+ return { kind: 'malformed', reason: 'X-Shopify-Triggered-At is no time' };
144
+ }
145
+ const received = now();
146
+ const kept = await db.query(`INSERT INTO baukasten.shopify_webhooks
147
+ (id, topic, shop, payload, triggered_at, received_at, next_at)
148
+ VALUES ($1, $2, $3, $4, $5, $6, $6)
149
+ ON CONFLICT (id) DO NOTHING RETURNING id`, [
150
+ id,
151
+ topic,
152
+ shop,
153
+ JSON.stringify(payload),
154
+ triggered === null ? received : new Date(triggered),
155
+ received
156
+ ]);
157
+ return kept.length === 0 ? { kind: 'repeated' } : { kind: 'queued' };
158
+ }
159
+ /** Applies the oldest webhook that is due, in its transaction; false when none is due. */
160
+ async function next() {
161
+ // The webhook the transaction took, for the record of its failure after the rollback.
162
+ const taken = { webhook: null };
163
+ try {
164
+ return await db.transaction(async (tx) => {
165
+ const [webhook] = await tx.query(`SELECT id, topic, shop, payload, triggered_at, attempts FROM baukasten.shopify_webhooks
166
+ WHERE applied_at IS NULL AND failed_at IS NULL AND next_at <= $1
167
+ ORDER BY received_at, id LIMIT 1 FOR UPDATE SKIP LOCKED`, [now()]);
168
+ if (webhook === undefined)
169
+ return false;
170
+ taken.webhook = webhook;
171
+ await apply(tx, webhook);
172
+ await tx.query(`UPDATE baukasten.shopify_webhooks SET applied_at = $2, shop = NULL, payload = NULL
173
+ WHERE id = $1`, [webhook.id, now()]);
174
+ return true;
175
+ });
176
+ }
177
+ catch (error) {
178
+ if (taken.webhook === null)
179
+ throw error;
180
+ await failed(taken.webhook, error);
181
+ return true;
182
+ }
183
+ }
184
+ async function failed(webhook, error) {
185
+ const attempts = webhook.attempts + 1;
186
+ const wait = RETRY_AFTER_MS[attempts - 1];
187
+ const about = { id: webhook.id, topic: webhook.topic, shop: webhook.shop, attempts, error };
188
+ if (wait === undefined) {
189
+ log.error('shopify: a webhook failed for the last time; it stays with its payload', about);
190
+ await db.query('UPDATE baukasten.shopify_webhooks SET attempts = $2, failed_at = $3 WHERE id = $1', [webhook.id, attempts, now()]);
191
+ return;
192
+ }
193
+ log.warn('shopify: a webhook failed; it is tried again', about);
194
+ await db.query('UPDATE baukasten.shopify_webhooks SET attempts = $2, next_at = $3 WHERE id = $1', [webhook.id, attempts, new Date(now().getTime() + wait)]);
195
+ }
196
+ /**
197
+ * Applies every webhook that is due, one after the other, and forgets those applied longer ago than
198
+ * KEPT_MS. Says when the next waits to be tried again (`retryAt`) and when the next applied one is
199
+ * to be forgotten (`forgetAt`), so that the run looks then; null for none.
200
+ */
201
+ async function work() {
202
+ for (;;) {
203
+ if (!(await next()))
204
+ break;
205
+ }
206
+ await db.query('DELETE FROM baukasten.shopify_webhooks WHERE applied_at <= $1', [
207
+ new Date(now().getTime() - KEPT_MS)
208
+ ]);
209
+ const [left] = await db.query(`SELECT min(next_at) FILTER (WHERE applied_at IS NULL AND failed_at IS NULL) AS retry_at,
210
+ min(applied_at) AS applied_at
211
+ FROM baukasten.shopify_webhooks`);
212
+ if (left === undefined)
213
+ throw new Error('min() gave no row');
214
+ return {
215
+ retryAt: left.retry_at,
216
+ forgetAt: left.applied_at === null ? null : new Date(left.applied_at.getTime() + KEPT_MS)
217
+ };
218
+ }
219
+ return { receive, work };
220
+ }
@@ -0,0 +1,15 @@
1
+ /** The stubs by their path in the product, with exactly their content. */
2
+ export declare const STUBS: Readonly<Record<string, string>>;
3
+ /**
4
+ * The files the product owns, by their path: the command writes each only where it is missing. The
5
+ * configuration fails loudly until it is filled in.
6
+ */
7
+ export declare const STARTING_POINTS: Readonly<Record<string, string>>;
8
+ /** The stubs of a Shopify app, by their path, with exactly their content. */
9
+ export declare const SHOPIFY_STUBS: Readonly<Record<string, string>>;
10
+ /**
11
+ * The files a Shopify app owns, by their path, written only where they are missing. The
12
+ * configuration fails loudly until it is filled in; shopify.app.toml waits for the subdomain of the
13
+ * app, its name, handle and scopes, and for the client ID, which linking the app gives it.
14
+ */
15
+ export declare const SHOPIFY_STARTING_POINTS: Readonly<Record<string, string>>;