@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.
- package/LICENSE +12 -0
- package/README.md +172 -0
- package/dist/app.css +2 -0
- package/dist/app.css.map +7 -0
- package/dist/app.js +56 -0
- package/dist/app.js.map +7 -0
- package/dist/index.html +22 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +7 -0
- package/package.json +66 -0
- package/src/app/App.tsx +37 -0
- package/src/app/Assistant.tsx +330 -0
- package/src/app/api.ts +126 -0
- package/src/app/context.ts +54 -0
- package/src/app/index.html +21 -0
- package/src/app/main.tsx +51 -0
- package/src/app/session-fetch.ts +31 -0
- package/src/config.ts +122 -0
- package/src/server/actions.ts +122 -0
- package/src/server/hanzo.ts +222 -0
- package/src/server/oauth.ts +165 -0
- package/src/server/server.ts +345 -0
- package/src/server/shopify-api.ts +278 -0
- package/src/server/webhooks.ts +90 -0
|
@@ -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
|
+
}
|