@hanzo/shopify 1.0.0

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.
@@ -0,0 +1,90 @@
1
+ // Shopify webhooks: HMAC signature verification and topic routing — all pure
2
+ // over their inputs so they are unit-testable without an http server. server.ts
3
+ // is the thin http glue that reads the raw body + headers and calls these.
4
+ // Crypto is Node's stdlib (node:crypto), never a dependency.
5
+ //
6
+ // Shopify signs every webhook with the app's API secret. Unlike the OAuth
7
+ // callback (hex over sorted query params), a webhook signature is:
8
+ // X-Shopify-Hmac-Sha256: base64(HMAC-SHA256(apiSecret, RAW_REQUEST_BODY))
9
+ // computed over the RAW body bytes. We must NOT JSON.parse-and-reserialize
10
+ // before verifying (key order/whitespace would differ and every webhook would
11
+ // fail). This is the whole trust boundary: a webhook that fails here is
12
+ // discarded before it is acted on. The shop + topic ride in headers
13
+ // (X-Shopify-Shop-Domain, X-Shopify-Topic). Docs:
14
+ // shopify.dev/docs/apps/build/webhooks/subscribe/verify-webhooks
15
+
16
+ import { createHmac, timingSafeEqual } from 'node:crypto';
17
+
18
+ // computeWebhookHmac is base64(HMAC-SHA256(apiSecret, rawBody)) — the value
19
+ // Shopify puts in X-Shopify-Hmac-Sha256. Both verification and any test fixture
20
+ // are built from it, so the value we compare is produced exactly as Shopify
21
+ // produces it.
22
+ export function computeWebhookHmac(apiSecret: string, rawBody: string): string {
23
+ return createHmac('sha256', apiSecret).update(rawBody, 'utf8').digest('base64');
24
+ }
25
+
26
+ // constantTimeEqualB64 compares two base64 digests without leaking
27
+ // length/position via timing. Different-length inputs are unequal and
28
+ // short-circuit safely (never feeding mismatched buffers to timingSafeEqual,
29
+ // which would throw).
30
+ export function constantTimeEqualB64(a: string, b: string): boolean {
31
+ const ba = Buffer.from(a, 'base64');
32
+ const bb = Buffer.from(b, 'base64');
33
+ if (ba.length === 0 || ba.length !== bb.length) return false;
34
+ return timingSafeEqual(ba, bb);
35
+ }
36
+
37
+ // verifyWebhook is the trust gate: recompute base64(HMAC-SHA256(apiSecret,
38
+ // rawBody)) and constant-time compare against the X-Shopify-Hmac-Sha256 header.
39
+ // Returns false on any missing input rather than throwing, so a malformed
40
+ // request is a clean reject.
41
+ export function verifyWebhook(apiSecret: string, headerHmac: string | undefined, rawBody: string): boolean {
42
+ if (!apiSecret || !headerHmac) return false;
43
+ return constantTimeEqualB64(headerHmac, computeWebhookHmac(apiSecret, rawBody));
44
+ }
45
+
46
+ // ---- Topic routing --------------------------------------------------------
47
+ //
48
+ // After a webhook is verified, we route on the X-Shopify-Topic header into a
49
+ // discriminated action so the server can dispatch. We act on order creation
50
+ // (auto-draft a support summary), app uninstall (drop the stored token), and
51
+ // the three GDPR "mandatory" topics every public app MUST handle to pass App
52
+ // Store review. Everything unknown is acknowledged and ignored — a clean 200.
53
+
54
+ export type WebhookAction =
55
+ | { kind: 'order_created'; shop: string; orderId: string }
56
+ | { kind: 'app_uninstalled'; shop: string }
57
+ | { kind: 'gdpr'; topic: string; shop: string }
58
+ | { kind: 'ignored'; topic: string; shop: string };
59
+
60
+ // The three GDPR/privacy topics Shopify requires a public app to subscribe to
61
+ // and respond to. shopify.dev/docs/apps/build/privacy-law-compliance
62
+ const GDPR_TOPICS = new Set([
63
+ 'customers/data_request',
64
+ 'customers/redact',
65
+ 'shop/redact',
66
+ ]);
67
+
68
+ // gid://shopify/Order/12345 → "gid://shopify/Order/12345". We keep the full GID
69
+ // (the GraphQL Admin API takes GIDs); the numeric REST `id` is a fallback. Pure.
70
+ function orderIdOf(body: any): string {
71
+ return String(body?.admin_graphql_api_id ?? body?.id ?? '');
72
+ }
73
+
74
+ // routeWebhook turns a topic + PARSED, ALREADY-VERIFIED body into a
75
+ // WebhookAction. Verification is the caller's job (verifyWebhook) — this is pure
76
+ // dispatch, unit-testable with plain objects. `shop` is the
77
+ // X-Shopify-Shop-Domain header value.
78
+ export function routeWebhook(topic: string, shop: string, body: any): WebhookAction {
79
+ const t = topic.toLowerCase();
80
+ if (t === 'orders/create') {
81
+ return { kind: 'order_created', shop, orderId: orderIdOf(body) };
82
+ }
83
+ if (t === 'app/uninstalled') {
84
+ return { kind: 'app_uninstalled', shop };
85
+ }
86
+ if (GDPR_TOPICS.has(t)) {
87
+ return { kind: 'gdpr', topic: t, shop };
88
+ }
89
+ return { kind: 'ignored', topic: t, shop };
90
+ }