hookwright 1.0.0 → 1.1.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/README.md +30 -8
- package/dist/cli.js +765 -78
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ hookwright # interactive UI — starts in the setup wizard on first
|
|
|
38
38
|
| Setup | asks for |
|
|
39
39
|
|---|---|
|
|
40
40
|
| **Shopify** | store domain + Admin API access token (needs `read_products`). Verifies immediately and picks up currency, country, province and contact details from the store record |
|
|
41
|
-
| **Webhook** |
|
|
41
|
+
| **Webhook** | one destination URL + signing secret **per integration** — Cashfree, Razorpay Magic and Nitro each get their own |
|
|
42
42
|
| **Customer** | the shopper the abandoned cart belongs to |
|
|
43
43
|
| **Defaults** | currency, platform |
|
|
44
44
|
|
|
@@ -50,14 +50,27 @@ Then **Integrations → Cashfree One Click Checkout** builds the payload:
|
|
|
50
50
|
2. Variant and quantity per product
|
|
51
51
|
3. Cart discount
|
|
52
52
|
4. Either accept the suggested values, or **review every field** — each one is pre-filled with the generated suggestion, enter accepts it, typing replaces it
|
|
53
|
-
5. Pre-flight report,
|
|
53
|
+
5. Pre-flight report — every check, the full field coverage table, the signed request and the complete payload
|
|
54
|
+
6. A **ready-to-paste `curl` is copied to your clipboard** automatically, then send
|
|
55
|
+
|
|
56
|
+
### The curl is built for pasting
|
|
57
|
+
|
|
58
|
+
The command is emitted in the exact shape Postman itself exports — `curl --location --request POST` with unindented `--header` / `--data` lines — and the body is inlined, never `@file`. Drop it straight into **Postman → Import → Raw text**, a colleague's terminal, or a bug report.
|
|
59
|
+
|
|
60
|
+
Single quotes inside product titles (`Levi's 501`) are escaped, and the signature travels with the exact body it signs, so a pasted command verifies precisely as the original request would.
|
|
61
|
+
|
|
62
|
+
Two things keep this honest: a test that executes the generated command verbatim against a receiver doing real HMAC verification, and a test that runs it through **`curl-to-postmanv2`** — the same parser Postman uses for Raw-text import — asserting the URL, method, every header and a byte-identical body all survive.
|
|
63
|
+
|
|
64
|
+
In the report screen: `c` re-copies the curl · `j` copies the raw payload JSON · `p` toggles the full payload view.
|
|
54
65
|
|
|
55
66
|
## Headless usage
|
|
56
67
|
|
|
57
68
|
Every command works without a TTY, for CI or scripting:
|
|
58
69
|
|
|
59
70
|
```bash
|
|
60
|
-
hookwright build --items 2
|
|
71
|
+
hookwright build --items 2 # cashfree-occ by default
|
|
72
|
+
hookwright build --provider razorpay-magic --items 2
|
|
73
|
+
hookwright build --provider nitro --event orders/create
|
|
61
74
|
hookwright build --search "cold brew" # only products matching a title
|
|
62
75
|
hookwright build --items 3 --discount 250 --send
|
|
63
76
|
hookwright send --dry-run # print the request + curl, send nothing
|
|
@@ -78,6 +91,7 @@ A generated payload is useless if the receiver silently drops it, so every build
|
|
|
78
91
|
- **Phone** — parsed to E.164 using the shipping `country_code`, the same way the consumer does.
|
|
79
92
|
- **Phone allow-list** — refuses to build for a number that isn't an approved test handset, so a live shopper can never be messaged by a test run.
|
|
80
93
|
- **Product images** — every `image_url` is `HEAD`-checked; unreachable images fail the build.
|
|
94
|
+
- **Endpoint exists** — before a signed payload is handed over, the destination is verified: the hostname must resolve, something must accept a TCP connection on the port, and the path must not 404. A typo'd host is caught here instead of looking like a silent delivery failure. The probe is `OPTIONS`, falling back to `HEAD` — **never a POST and never a body**, so it cannot create data on the receiving system. Timeouts and servers that reject `OPTIONS` warn rather than block. Skip with `--no-endpoint-check`, override with `--force`.
|
|
81
95
|
- **Field coverage** — a table of every field the consumer reads, what it maps to, and its value. Missing required fields are flagged in red.
|
|
82
96
|
|
|
83
97
|
Sending is also blocked when the target looks like production, unless `--force`.
|
|
@@ -94,18 +108,26 @@ Override both locations with `HOOKWRIGHT_HOME`, or point at one config with `--c
|
|
|
94
108
|
|
|
95
109
|
## Providers
|
|
96
110
|
|
|
97
|
-
| provider | signature | headers |
|
|
98
|
-
|
|
99
|
-
| `cashfree-occ` | base64 HMAC-SHA256 over `timestamp + body` | `x-webhook-signature`, `x-webhook-timestamp` |
|
|
111
|
+
| provider | signature | headers | events |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `cashfree-occ` | base64 HMAC-SHA256 over `timestamp + body` | `x-webhook-signature`, `x-webhook-timestamp` | abandoned checkout |
|
|
114
|
+
| `razorpay-magic` | hex HMAC-SHA256 over the body | `x-razorpay-signature` | abandoned checkout |
|
|
115
|
+
| `nitro` | static bearer token, no body signature | `authorization` | 8 — page view, category view, product view, add to cart, remove from cart, checkout, order created, order updated |
|
|
116
|
+
|
|
117
|
+
Each provider owns its gate, signature scheme, field map, payload shape and summary, so nothing is assumed across them:
|
|
118
|
+
|
|
119
|
+
- **Cashfree OCC** nests everything under `data`; currency is read from `line_items[0]`, not the root.
|
|
120
|
+
- **Razorpay Magic** is root-level, uses capitalised shipping keys (`Shipping_address.Address1`) and sends `line_items_total` as a **string in paise** — matching [Razorpay's abandoned cart webhook docs](https://razorpay.com/docs/payments/magic-checkout/abandoned-cart/). Its gate is "non-empty `abandoned_checkout_url`" rather than an exact value.
|
|
121
|
+
- **Nitro** keys off `eventName` and requires `eventVal.customer.phone` on *every* event, parsed against root `country`. Choose the event with `--event`, or from the menu in the UI.
|
|
100
122
|
|
|
101
|
-
Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`, `webhookUrl`, a `gate` and a `fieldMap` — see [docs/cashfree-occ.md](docs/cashfree-occ.md).
|
|
123
|
+
Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`, `webhookUrl`, `summarize`, a `gate` and a `fieldMap` — see [docs/cashfree-occ.md](docs/cashfree-occ.md).
|
|
102
124
|
|
|
103
125
|
## Development
|
|
104
126
|
|
|
105
127
|
```bash
|
|
106
128
|
npm install
|
|
107
129
|
npm run dev # esbuild watch
|
|
108
|
-
npm test #
|
|
130
|
+
npm test # 138 tests, no network required
|
|
109
131
|
npm start # build + run
|
|
110
132
|
```
|
|
111
133
|
|
package/dist/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ var init_package = __esm({
|
|
|
24
24
|
"package.json"() {
|
|
25
25
|
package_default = {
|
|
26
26
|
name: "hookwright",
|
|
27
|
-
version: "1.
|
|
27
|
+
version: "1.1.0",
|
|
28
28
|
description: "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
|
|
29
29
|
keywords: [
|
|
30
30
|
"webhook",
|
|
@@ -63,6 +63,7 @@ var init_package = __esm({
|
|
|
63
63
|
react: "^19.2.8"
|
|
64
64
|
},
|
|
65
65
|
devDependencies: {
|
|
66
|
+
"curl-to-postmanv2": "^1.8.7",
|
|
66
67
|
esbuild: "^0.28.2"
|
|
67
68
|
}
|
|
68
69
|
};
|
|
@@ -551,6 +552,20 @@ var editableFields = [
|
|
|
551
552
|
{ group: "UTM", path: "data.utm_parameters.utm_content", label: "UTM content", optional: true },
|
|
552
553
|
{ group: "UTM", path: "data.utm_parameters.fbclid", label: "Facebook click id", optional: true }
|
|
553
554
|
];
|
|
555
|
+
function summarize(payload) {
|
|
556
|
+
const d = payload?.data ?? {};
|
|
557
|
+
const items = Array.isArray(d.line_items) ? d.line_items : [];
|
|
558
|
+
return {
|
|
559
|
+
event: "abandoned checkout",
|
|
560
|
+
store: d.store_url,
|
|
561
|
+
customer: `${d.customer?.shipping_address?.customer_name ?? ""} \xB7 ${d.phone ?? ""}`.trim(),
|
|
562
|
+
items: items.map((i) => `${i.name} x${i.quantity}`).join(", "),
|
|
563
|
+
total: d.total_price,
|
|
564
|
+
currency: items[0]?.currency,
|
|
565
|
+
extra: `was ${d.original_total_price}, discount ${d.total_discount}`,
|
|
566
|
+
link: d.abandoned_checkout_url
|
|
567
|
+
};
|
|
568
|
+
}
|
|
554
569
|
var cashfree_occ_default = {
|
|
555
570
|
id: "cashfree-occ",
|
|
556
571
|
urlKey: "cashfree_occ",
|
|
@@ -564,7 +579,461 @@ var cashfree_occ_default = {
|
|
|
564
579
|
buildPayload,
|
|
565
580
|
sign,
|
|
566
581
|
webhookUrl,
|
|
567
|
-
webhookSecret
|
|
582
|
+
webhookSecret,
|
|
583
|
+
summarize
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// src/providers/razorpay-magic.mjs
|
|
587
|
+
import crypto2 from "node:crypto";
|
|
588
|
+
var GATE_PATH2 = "abandoned_checkout_url";
|
|
589
|
+
var fieldMap2 = [
|
|
590
|
+
{ source: "abandoned_checkout_url", target: "\xABgate\xBB + abandonedCheckoutUrl", required: true, note: "must be non-empty or the event is dropped" },
|
|
591
|
+
{ source: "created_at", target: "eventTime" },
|
|
592
|
+
{ source: "shop_id", target: "storeUrl", required: true, note: "shopify_domain / store_url also accepted" },
|
|
593
|
+
{ source: "email", target: "email" },
|
|
594
|
+
{ source: "phone", target: "phone", required: true, note: "falls back to Shipping_address.Phone" },
|
|
595
|
+
{ source: "currency", target: "pricing.currency", required: true, note: "read from the ROOT, unlike Cashfree" },
|
|
596
|
+
{ source: "line_items_total", target: "pricing.totalPrice", required: true, note: "documented as a STRING in paise \u2014 the consumer parseFloats it and divides by 100" },
|
|
597
|
+
{ source: "platform", target: "\u2014", note: "documented root field" },
|
|
598
|
+
{ source: "utm_parameters", target: "\u2014", note: "documented root field" },
|
|
599
|
+
{ source: "customer.first_name", target: "customer.firstName" },
|
|
600
|
+
{ source: "customer.last_name", target: "customer.lastName" },
|
|
601
|
+
{ source: "customer.email", target: "customer.email" },
|
|
602
|
+
{ source: "customer.Shipping_address.Name", target: "customer.name" },
|
|
603
|
+
{ source: "customer.Shipping_address.First_name", target: "customer.firstName fallback" },
|
|
604
|
+
{ source: "customer.Shipping_address.Last_name", target: "customer.lastName fallback" },
|
|
605
|
+
{ source: "customer.Shipping_address.Phone", target: "phone fallback" },
|
|
606
|
+
{ source: "customer.Shipping_address.Address1", target: "customer.address1" },
|
|
607
|
+
{ source: "customer.Shipping_address.Address2", target: "customer.address2" },
|
|
608
|
+
{ source: "customer.Shipping_address.City", target: "customer.city" },
|
|
609
|
+
{ source: "customer.Shipping_address.Province", target: "customer.province" },
|
|
610
|
+
{ source: "customer.Shipping_address.Country_name", target: "customer.country" },
|
|
611
|
+
{ source: "customer.Shipping_address.Country_code", target: "customer.countryCode", required: true, note: "region used to parse the phone" },
|
|
612
|
+
{ source: "customer.Shipping_address.Zip", target: "customer.zip" },
|
|
613
|
+
{ source: "line_items[0].name", target: "cart.productName", required: true },
|
|
614
|
+
{ source: "line_items[0].image_url", target: "cart.image", required: true },
|
|
615
|
+
{ source: "line_items[0].quantity", target: "cart.quantity" }
|
|
616
|
+
];
|
|
617
|
+
var editableFields2 = [
|
|
618
|
+
{ group: "Checkout", path: "abandoned_checkout_url", label: "Abandoned checkout URL", hint: "must be non-empty \u2014 this is the schema gate" },
|
|
619
|
+
{ group: "Checkout", path: "created_at", label: "Created at" },
|
|
620
|
+
{ group: "Checkout", path: "shop_id", label: "Shop domain" },
|
|
621
|
+
{ group: "Checkout", path: "cart_token", label: "Cart token" },
|
|
622
|
+
{ group: "Pricing", path: "line_items_total", label: "Line items total (paise, string)", hint: "documented as a string; the consumer divides by 100" },
|
|
623
|
+
{ group: "Pricing", path: "currency", label: "Currency" },
|
|
624
|
+
{ group: "Customer", path: "customer.first_name", label: "First name" },
|
|
625
|
+
{ group: "Customer", path: "customer.last_name", label: "Last name" },
|
|
626
|
+
{ group: "Customer", path: "email", label: "Email" },
|
|
627
|
+
{ group: "Customer", path: "phone", label: "Phone" },
|
|
628
|
+
{ group: "Shipping", path: "customer.Shipping_address.Name", label: "Ship-to name" },
|
|
629
|
+
{ group: "Shipping", path: "customer.Shipping_address.Address1", label: "Address line 1" },
|
|
630
|
+
{ group: "Shipping", path: "customer.Shipping_address.Address2", label: "Address line 2", optional: true },
|
|
631
|
+
{ group: "Shipping", path: "customer.Shipping_address.City", label: "City" },
|
|
632
|
+
{ group: "Shipping", path: "customer.Shipping_address.Province", label: "Province / state" },
|
|
633
|
+
{ group: "Shipping", path: "customer.Shipping_address.Province_code", label: "Province code", optional: true },
|
|
634
|
+
{ group: "Shipping", path: "customer.Shipping_address.Country_name", label: "Country" },
|
|
635
|
+
{ group: "Shipping", path: "customer.Shipping_address.Country_code", label: "Country code", hint: "region used to parse the phone" },
|
|
636
|
+
{ group: "Shipping", path: "customer.Shipping_address.Zip", label: "Zip / postcode" }
|
|
637
|
+
];
|
|
638
|
+
function buildPayload2(ctx) {
|
|
639
|
+
const { config, items, phone } = ctx;
|
|
640
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
641
|
+
const currency = ctx.currency || config.defaults.currency;
|
|
642
|
+
const domain = config.shopify.domain;
|
|
643
|
+
const cust = config.customer;
|
|
644
|
+
const lineItems = items.map(({ product, variant, quantity }) => {
|
|
645
|
+
const price = money2(variant?.price ?? 0);
|
|
646
|
+
return {
|
|
647
|
+
image_url: imageForVariant(product, variant),
|
|
648
|
+
name: product.title,
|
|
649
|
+
title: product.title,
|
|
650
|
+
price,
|
|
651
|
+
variant_price: price,
|
|
652
|
+
product_id: String(product.id),
|
|
653
|
+
variant_id: variant ? String(variant.id) : null,
|
|
654
|
+
variant_title: variant?.title ?? null,
|
|
655
|
+
quantity,
|
|
656
|
+
sku: variant?.sku || null,
|
|
657
|
+
tax_amount: 0,
|
|
658
|
+
taxable: true,
|
|
659
|
+
store_name: config.shopify.shop?.name ?? domain,
|
|
660
|
+
gram: "0"
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
const total = money2(lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0));
|
|
664
|
+
const discount = money2(Math.min(ctx.discount ?? 0, total));
|
|
665
|
+
const net2 = money2(total - discount);
|
|
666
|
+
const checkoutUrl = cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
|
|
667
|
+
return {
|
|
668
|
+
shop_id: domain,
|
|
669
|
+
shopify_domain: domain,
|
|
670
|
+
platform: config.defaults.platform,
|
|
671
|
+
token: crypto2.randomBytes(16).toString("hex"),
|
|
672
|
+
cart_token: crypto2.randomBytes(16).toString("hex"),
|
|
673
|
+
email: cust.email,
|
|
674
|
+
phone,
|
|
675
|
+
abandoned_checkout_url: checkoutUrl,
|
|
676
|
+
currency,
|
|
677
|
+
// Documented as a string, in the smallest currency unit (paise).
|
|
678
|
+
// The consumer parses it with parseFloat and divides by 100.
|
|
679
|
+
line_items_total: String(Math.round(net2 * 100)),
|
|
680
|
+
line_items: lineItems,
|
|
681
|
+
tax_details: {},
|
|
682
|
+
promotions: [],
|
|
683
|
+
utm_parameters: { ...config.defaults.utm },
|
|
684
|
+
// Not in the documented field list, but the consumer reads it for eventTime.
|
|
685
|
+
created_at: now.toISOString(),
|
|
686
|
+
customer: {
|
|
687
|
+
email: cust.email,
|
|
688
|
+
first_name: cust.firstName,
|
|
689
|
+
last_name: cust.lastName,
|
|
690
|
+
created_at: now.toISOString(),
|
|
691
|
+
Shipping_address: {
|
|
692
|
+
Name: `${cust.firstName} ${cust.lastName}`.trim(),
|
|
693
|
+
First_name: cust.firstName,
|
|
694
|
+
Last_name: cust.lastName,
|
|
695
|
+
Phone: phone,
|
|
696
|
+
Address1: cust.address1,
|
|
697
|
+
Address2: cust.address2,
|
|
698
|
+
City: cust.city,
|
|
699
|
+
Province: cust.province,
|
|
700
|
+
Province_code: cust.provinceCode,
|
|
701
|
+
Country: cust.country,
|
|
702
|
+
Country_name: cust.country,
|
|
703
|
+
Country_code: cust.countryCode,
|
|
704
|
+
Zip: cust.zip
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function sign2({ body, secret }) {
|
|
710
|
+
const signature = crypto2.createHmac("sha256", String(secret)).update(body).digest("hex");
|
|
711
|
+
return {
|
|
712
|
+
signature,
|
|
713
|
+
timestamp: null,
|
|
714
|
+
headers: {
|
|
715
|
+
"content-type": "application/json",
|
|
716
|
+
"x-razorpay-signature": signature
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function webhookUrl2(config) {
|
|
721
|
+
return String(targetFor(config, "razorpay-magic").webhookUrl ?? "").trim();
|
|
722
|
+
}
|
|
723
|
+
function webhookSecret2(config) {
|
|
724
|
+
return String(targetFor(config, "razorpay-magic").webhookSecret ?? "");
|
|
725
|
+
}
|
|
726
|
+
function summarize2(payload) {
|
|
727
|
+
const items = Array.isArray(payload?.line_items) ? payload.line_items : [];
|
|
728
|
+
const ship = payload?.customer?.Shipping_address ?? {};
|
|
729
|
+
return {
|
|
730
|
+
event: "abandoned checkout",
|
|
731
|
+
store: payload?.shop_id,
|
|
732
|
+
customer: `${ship.Name ?? ""} \xB7 ${payload?.phone ?? ""}`.trim(),
|
|
733
|
+
items: items.map((i) => `${i.name} x${i.quantity}`).join(", "),
|
|
734
|
+
total: Number.parseFloat(payload?.line_items_total ?? "0") / 100,
|
|
735
|
+
currency: payload?.currency,
|
|
736
|
+
extra: `${payload?.line_items_total} paise`,
|
|
737
|
+
link: payload?.abandoned_checkout_url
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
var razorpay_magic_default = {
|
|
741
|
+
id: "razorpay-magic",
|
|
742
|
+
urlKey: "razorpay_magic",
|
|
743
|
+
label: "Razorpay Magic",
|
|
744
|
+
eventType: "razorpay_magic_abandoned_checkout",
|
|
745
|
+
gate: { path: GATE_PATH2, value: void 0, nonEmpty: true },
|
|
746
|
+
signatureScheme: "hex HMAC-SHA256 over the body",
|
|
747
|
+
signatureHeader: "x-razorpay-signature",
|
|
748
|
+
fieldMap: fieldMap2,
|
|
749
|
+
editableFields: editableFields2,
|
|
750
|
+
buildPayload: buildPayload2,
|
|
751
|
+
sign: sign2,
|
|
752
|
+
webhookUrl: webhookUrl2,
|
|
753
|
+
webhookSecret: webhookSecret2,
|
|
754
|
+
summarize: summarize2
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// src/providers/nitro.mjs
|
|
758
|
+
import crypto3 from "node:crypto";
|
|
759
|
+
var EVENTS = [
|
|
760
|
+
{ eventName: "view", type: "nitro_view", label: "Page View" },
|
|
761
|
+
{ eventName: "category_view", type: "nitro_category_view", label: "Category View" },
|
|
762
|
+
{ eventName: "product_view", type: "nitro_product_view", label: "Product View" },
|
|
763
|
+
{ eventName: "addtocart", type: "nitro_addtocart", label: "Add To Cart" },
|
|
764
|
+
{ eventName: "removefromcart", type: "nitro_removefromcart", label: "Remove From Cart" },
|
|
765
|
+
{ eventName: "checkout", type: "nitro_checkout", label: "Checkout" },
|
|
766
|
+
{ eventName: "orders/create", type: "nitro_orders_create", label: "Order Created" },
|
|
767
|
+
{ eventName: "orders/updated", type: "nitro_orders_updated", label: "Order Updated" }
|
|
768
|
+
];
|
|
769
|
+
var DEFAULT_EVENT = "addtocart";
|
|
770
|
+
var COMMON_FIELDS = [
|
|
771
|
+
{ source: "eventName", target: "\xABgate\xBB", required: true, note: "must match the schema exactly" },
|
|
772
|
+
{ source: "country", target: "phone region", required: true, note: "used to parse eventVal.customer.phone" },
|
|
773
|
+
{ source: "eventVal.customer.phone", target: "phone", required: true, note: "missing phone is rejected on every event" }
|
|
774
|
+
];
|
|
775
|
+
var PAGE_FIELDS = [
|
|
776
|
+
{ source: "eventVal.page", target: "eventVal.page" },
|
|
777
|
+
{ source: "eventVal.h", target: "eventVal.h", note: "host" },
|
|
778
|
+
{ source: "eventVal.l", target: "eventVal.l", note: "location" },
|
|
779
|
+
{ source: "eventVal._ss", target: "eventVal._ss", note: "session" }
|
|
780
|
+
];
|
|
781
|
+
var FIELD_MAPS = {
|
|
782
|
+
view: [...COMMON_FIELDS, ...PAGE_FIELDS],
|
|
783
|
+
category_view: [
|
|
784
|
+
...COMMON_FIELDS,
|
|
785
|
+
...PAGE_FIELDS,
|
|
786
|
+
{ source: "eventVal.resource_id", target: "eventVal.resource_id", required: true },
|
|
787
|
+
{ source: "eventVal.resource", target: "eventVal.resource" },
|
|
788
|
+
{ source: "eventVal.domain", target: "eventVal.domain" }
|
|
789
|
+
],
|
|
790
|
+
product_view: [
|
|
791
|
+
...COMMON_FIELDS,
|
|
792
|
+
...PAGE_FIELDS,
|
|
793
|
+
{ source: "eventVal.title", target: "eventVal.title", required: true },
|
|
794
|
+
{ source: "eventVal.image", target: "eventVal.image", required: true },
|
|
795
|
+
{ source: "eventVal.price", target: "eventVal.price", required: true },
|
|
796
|
+
{ source: "eventVal.resource_id", target: "eventVal.resource_id", required: true },
|
|
797
|
+
{ source: "eventVal.resource", target: "eventVal.resource" },
|
|
798
|
+
{ source: "eventVal.domain", target: "eventVal.domain" }
|
|
799
|
+
],
|
|
800
|
+
addtocart: [
|
|
801
|
+
...COMMON_FIELDS,
|
|
802
|
+
{ source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
|
|
803
|
+
{ source: "eventVal.init_cart", target: "eventVal.init_cart" },
|
|
804
|
+
{ source: "eventVal.empty_cart", target: "eventVal.empty_cart" },
|
|
805
|
+
{ source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" },
|
|
806
|
+
{ source: "eventVal.line_items[0].name", target: "cart enrichment", required: true }
|
|
807
|
+
],
|
|
808
|
+
removefromcart: [
|
|
809
|
+
...COMMON_FIELDS,
|
|
810
|
+
{ source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
|
|
811
|
+
{ source: "eventVal.init_cart", target: "eventVal.init_cart" },
|
|
812
|
+
{ source: "eventVal.empty_cart", target: "eventVal.empty_cart" },
|
|
813
|
+
{ source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" },
|
|
814
|
+
{ source: "eventVal.line_items[0].name", target: "cart enrichment" }
|
|
815
|
+
],
|
|
816
|
+
checkout: [
|
|
817
|
+
...COMMON_FIELDS,
|
|
818
|
+
{ source: "eventVal.checkout", target: "eventVal.checkout", required: true },
|
|
819
|
+
{ source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
|
|
820
|
+
{ source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" }
|
|
821
|
+
],
|
|
822
|
+
"orders/create": [
|
|
823
|
+
...COMMON_FIELDS,
|
|
824
|
+
{ source: "eventVal.order_id", target: "eventVal.order_id", required: true },
|
|
825
|
+
{ source: "eventVal.order_number", target: "eventVal.order_number" },
|
|
826
|
+
{ source: "eventVal.order_name", target: "eventVal.order_name" },
|
|
827
|
+
{ source: "eventVal.price", target: "eventVal.price", required: true },
|
|
828
|
+
{ source: "eventVal.currency", target: "eventVal.currency", required: true },
|
|
829
|
+
{ source: "eventVal.url", target: "eventVal.url" },
|
|
830
|
+
{ source: "eventVal.order_created_at", target: "eventVal.order_created_at" },
|
|
831
|
+
{ source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" }
|
|
832
|
+
]
|
|
833
|
+
};
|
|
834
|
+
FIELD_MAPS["orders/updated"] = FIELD_MAPS["orders/create"];
|
|
835
|
+
var fieldMap3 = FIELD_MAPS[DEFAULT_EVENT];
|
|
836
|
+
var EDITABLE = {
|
|
837
|
+
common: [
|
|
838
|
+
{ group: "Event", path: "eventName", label: "Event name", hint: "the schema gate \u2014 must match exactly" },
|
|
839
|
+
{ group: "Event", path: "country", label: "Country code", hint: "region used to parse the phone" },
|
|
840
|
+
{ group: "Event", path: "timestamp", label: "Timestamp" },
|
|
841
|
+
{ group: "Customer", path: "eventVal.customer.phone", label: "Phone", hint: "required on every Nitro event" },
|
|
842
|
+
{ group: "Customer", path: "eventVal.customer.email", label: "Email" },
|
|
843
|
+
{ group: "Customer", path: "eventVal.customer.name", label: "Name" }
|
|
844
|
+
],
|
|
845
|
+
page: [
|
|
846
|
+
{ group: "Page", path: "eventVal.page", label: "Page" },
|
|
847
|
+
{ group: "Page", path: "eventVal.h", label: "Host" },
|
|
848
|
+
{ group: "Page", path: "eventVal.l", label: "Location URL" },
|
|
849
|
+
{ group: "Page", path: "eventVal._ss", label: "Session id" }
|
|
850
|
+
],
|
|
851
|
+
cart: [
|
|
852
|
+
{ group: "Cart", path: "eventVal.cart_value", label: "Cart value", type: "number" },
|
|
853
|
+
{ group: "Cart", path: "eventVal.init_cart", label: "Init cart" },
|
|
854
|
+
{ group: "Cart", path: "eventVal.empty_cart", label: "Empty cart" },
|
|
855
|
+
{ group: "Cart", path: "eventVal.bik_customer_id", label: "Bik customer id" }
|
|
856
|
+
],
|
|
857
|
+
order: [
|
|
858
|
+
{ group: "Order", path: "eventVal.order_id", label: "Order id" },
|
|
859
|
+
{ group: "Order", path: "eventVal.order_number", label: "Order number" },
|
|
860
|
+
{ group: "Order", path: "eventVal.order_name", label: "Order name" },
|
|
861
|
+
{ group: "Order", path: "eventVal.price", label: "Price", type: "number" },
|
|
862
|
+
{ group: "Order", path: "eventVal.currency", label: "Currency" },
|
|
863
|
+
{ group: "Order", path: "eventVal.url", label: "Order URL" }
|
|
864
|
+
]
|
|
865
|
+
};
|
|
866
|
+
function editableFieldsFor(eventName) {
|
|
867
|
+
const e = EDITABLE;
|
|
868
|
+
if (eventName === "view") return [...e.common, ...e.page];
|
|
869
|
+
if (eventName === "category_view" || eventName === "product_view") return [...e.common, ...e.page];
|
|
870
|
+
if (eventName === "addtocart" || eventName === "removefromcart") return [...e.common, ...e.cart];
|
|
871
|
+
if (eventName === "checkout") return [...e.common, ...e.cart];
|
|
872
|
+
if (eventName.startsWith("orders/")) return [...e.common, ...e.order];
|
|
873
|
+
return e.common;
|
|
874
|
+
}
|
|
875
|
+
var editableFields3 = editableFieldsFor(DEFAULT_EVENT);
|
|
876
|
+
function buildPayload3(ctx) {
|
|
877
|
+
const { config, items, phone } = ctx;
|
|
878
|
+
const eventName = ctx.eventName ?? DEFAULT_EVENT;
|
|
879
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
880
|
+
const currency = ctx.currency || config.defaults.currency;
|
|
881
|
+
const domain = config.shopify.domain;
|
|
882
|
+
const cust = config.customer;
|
|
883
|
+
const session = crypto3.randomBytes(8).toString("hex");
|
|
884
|
+
const first = items[0];
|
|
885
|
+
const lineItems = items.map(({ product, variant, quantity }) => ({
|
|
886
|
+
id: variant ? String(variant.id) : null,
|
|
887
|
+
product_id: String(product.id),
|
|
888
|
+
name: product.title,
|
|
889
|
+
title: product.title,
|
|
890
|
+
quantity,
|
|
891
|
+
price: money2(variant?.price ?? 0),
|
|
892
|
+
image: imageForVariant(product, variant),
|
|
893
|
+
url: `https://${domain}/products/${product.handle ?? ""}`
|
|
894
|
+
}));
|
|
895
|
+
const cartValue = money2(lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0));
|
|
896
|
+
const productUrl = first ? `https://${domain}/products/${first.product.handle ?? ""}` : `https://${domain}`;
|
|
897
|
+
const customer = {
|
|
898
|
+
phone,
|
|
899
|
+
email: cust.email,
|
|
900
|
+
name: `${cust.firstName} ${cust.lastName}`.trim(),
|
|
901
|
+
first_name: cust.firstName,
|
|
902
|
+
last_name: cust.lastName
|
|
903
|
+
};
|
|
904
|
+
const base = {
|
|
905
|
+
org_token: config.defaults.orgToken ?? crypto3.randomBytes(12).toString("hex"),
|
|
906
|
+
eventName,
|
|
907
|
+
userId: crypto3.randomUUID(),
|
|
908
|
+
timestamp: now.toISOString(),
|
|
909
|
+
country: cust.countryCode,
|
|
910
|
+
domain
|
|
911
|
+
};
|
|
912
|
+
const page = {
|
|
913
|
+
page: eventName === "product_view" ? "product" : eventName === "category_view" ? "collection" : "home",
|
|
914
|
+
h: domain,
|
|
915
|
+
l: productUrl,
|
|
916
|
+
_ss: session,
|
|
917
|
+
domain
|
|
918
|
+
};
|
|
919
|
+
let eventVal;
|
|
920
|
+
switch (eventName) {
|
|
921
|
+
case "view":
|
|
922
|
+
eventVal = { ...page, page: "home", l: `https://${domain}`, customer };
|
|
923
|
+
break;
|
|
924
|
+
case "category_view":
|
|
925
|
+
eventVal = {
|
|
926
|
+
...page,
|
|
927
|
+
resource: "collection",
|
|
928
|
+
resource_id: String(first?.product?.id ?? ""),
|
|
929
|
+
l: `https://${domain}/collections/all`,
|
|
930
|
+
customer
|
|
931
|
+
};
|
|
932
|
+
break;
|
|
933
|
+
case "product_view":
|
|
934
|
+
eventVal = {
|
|
935
|
+
...page,
|
|
936
|
+
resource: "product",
|
|
937
|
+
resource_id: String(first?.product?.id ?? ""),
|
|
938
|
+
title: first?.product?.title,
|
|
939
|
+
image: first ? imageForVariant(first.product, first.variant) : void 0,
|
|
940
|
+
price: money2(first?.variant?.price ?? 0),
|
|
941
|
+
customer
|
|
942
|
+
};
|
|
943
|
+
break;
|
|
944
|
+
case "addtocart":
|
|
945
|
+
case "removefromcart":
|
|
946
|
+
eventVal = {
|
|
947
|
+
cart_value: cartValue,
|
|
948
|
+
init_cart: eventName === "addtocart",
|
|
949
|
+
empty_cart: eventName === "removefromcart" && items.length === 0,
|
|
950
|
+
bik_customer_id: crypto3.randomUUID(),
|
|
951
|
+
line_items: lineItems,
|
|
952
|
+
[eventName === "addtocart" ? "items_added" : "items_removed"]: lineItems,
|
|
953
|
+
customer
|
|
954
|
+
};
|
|
955
|
+
break;
|
|
956
|
+
case "checkout":
|
|
957
|
+
eventVal = {
|
|
958
|
+
checkout: cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
|
|
959
|
+
cart_value: cartValue,
|
|
960
|
+
bik_customer_id: crypto3.randomUUID(),
|
|
961
|
+
line_items: lineItems,
|
|
962
|
+
customer
|
|
963
|
+
};
|
|
964
|
+
break;
|
|
965
|
+
case "orders/create":
|
|
966
|
+
case "orders/updated": {
|
|
967
|
+
const orderNumber = 1e3 + Math.floor(Math.random() * 9e3);
|
|
968
|
+
eventVal = {
|
|
969
|
+
url: `https://${domain}/admin/orders/${orderNumber}`,
|
|
970
|
+
order_id: String(Date.now()),
|
|
971
|
+
order_number: orderNumber,
|
|
972
|
+
order_name: `#${orderNumber}`,
|
|
973
|
+
price: cartValue,
|
|
974
|
+
currency,
|
|
975
|
+
order_created_at: now.toISOString(),
|
|
976
|
+
bik_customer_id: crypto3.randomUUID(),
|
|
977
|
+
line_items: lineItems,
|
|
978
|
+
customer
|
|
979
|
+
};
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
default:
|
|
983
|
+
eventVal = { customer };
|
|
984
|
+
}
|
|
985
|
+
return { ...base, eventVal };
|
|
986
|
+
}
|
|
987
|
+
function sign3({ secret }) {
|
|
988
|
+
return {
|
|
989
|
+
signature: null,
|
|
990
|
+
timestamp: null,
|
|
991
|
+
headers: {
|
|
992
|
+
"content-type": "application/json",
|
|
993
|
+
authorization: `Bearer ${secret}`
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function webhookUrl3(config) {
|
|
998
|
+
return String(targetFor(config, "nitro").webhookUrl ?? "").trim();
|
|
999
|
+
}
|
|
1000
|
+
function webhookSecret3(config) {
|
|
1001
|
+
return String(targetFor(config, "nitro").webhookSecret ?? "");
|
|
1002
|
+
}
|
|
1003
|
+
function summarize3(payload) {
|
|
1004
|
+
const ev = payload?.eventVal ?? {};
|
|
1005
|
+
const items = Array.isArray(ev.line_items) ? ev.line_items : [];
|
|
1006
|
+
return {
|
|
1007
|
+
event: payload?.eventName,
|
|
1008
|
+
store: payload?.domain,
|
|
1009
|
+
customer: `${ev.customer?.name ?? ""} \xB7 ${ev.customer?.phone ?? ""}`.trim(),
|
|
1010
|
+
items: items.map((i) => `${i.name} x${i.quantity}`).join(", ") || "\u2014",
|
|
1011
|
+
total: ev.cart_value ?? ev.price ?? void 0,
|
|
1012
|
+
currency: ev.currency,
|
|
1013
|
+
extra: ev.order_name ? `order ${ev.order_name}` : ev.checkout ? "checkout started" : "",
|
|
1014
|
+
link: ev.checkout ?? ev.url ?? ev.l
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
var nitro_default = {
|
|
1018
|
+
id: "nitro",
|
|
1019
|
+
urlKey: "nitro",
|
|
1020
|
+
label: "Nitro",
|
|
1021
|
+
events: EVENTS,
|
|
1022
|
+
defaultEvent: DEFAULT_EVENT,
|
|
1023
|
+
eventType: "nitro_addtocart",
|
|
1024
|
+
gate: { path: "eventName", value: DEFAULT_EVENT },
|
|
1025
|
+
gateFor: (eventName) => ({ path: "eventName", value: eventName }),
|
|
1026
|
+
signatureScheme: "static bearer token (no body signature)",
|
|
1027
|
+
signatureHeader: "authorization",
|
|
1028
|
+
fieldMap: fieldMap3,
|
|
1029
|
+
fieldMapFor: (eventName) => FIELD_MAPS[eventName] ?? FIELD_MAPS[DEFAULT_EVENT],
|
|
1030
|
+
editableFields: editableFields3,
|
|
1031
|
+
editableFieldsFor,
|
|
1032
|
+
buildPayload: buildPayload3,
|
|
1033
|
+
sign: sign3,
|
|
1034
|
+
webhookUrl: webhookUrl3,
|
|
1035
|
+
webhookSecret: webhookSecret3,
|
|
1036
|
+
summarize: summarize3
|
|
568
1037
|
};
|
|
569
1038
|
|
|
570
1039
|
// src/providers/index.mjs
|
|
@@ -581,18 +1050,21 @@ var PROVIDER_META = [
|
|
|
581
1050
|
label: "Razorpay Magic",
|
|
582
1051
|
signature: "hex HMAC-SHA256 over the body",
|
|
583
1052
|
header: "x-razorpay-signature",
|
|
584
|
-
available:
|
|
1053
|
+
available: true
|
|
585
1054
|
},
|
|
586
1055
|
{
|
|
587
1056
|
id: "nitro",
|
|
588
1057
|
label: "Nitro",
|
|
589
1058
|
signature: "static bearer token",
|
|
590
1059
|
header: "authorization",
|
|
591
|
-
available:
|
|
1060
|
+
available: true,
|
|
1061
|
+
events: nitro_default.events
|
|
592
1062
|
}
|
|
593
1063
|
];
|
|
594
1064
|
var providers = {
|
|
595
|
-
[cashfree_occ_default.id]: cashfree_occ_default
|
|
1065
|
+
[cashfree_occ_default.id]: cashfree_occ_default,
|
|
1066
|
+
[razorpay_magic_default.id]: razorpay_magic_default,
|
|
1067
|
+
[nitro_default.id]: nitro_default
|
|
596
1068
|
};
|
|
597
1069
|
function getProvider(id) {
|
|
598
1070
|
const provider = providers[id];
|
|
@@ -988,6 +1460,113 @@ function setPath(obj, path6, value) {
|
|
|
988
1460
|
return obj;
|
|
989
1461
|
}
|
|
990
1462
|
|
|
1463
|
+
// src/core/reachability.mjs
|
|
1464
|
+
import dns from "node:dns/promises";
|
|
1465
|
+
import net from "node:net";
|
|
1466
|
+
function parseEndpoint(url) {
|
|
1467
|
+
const raw = String(url ?? "").trim();
|
|
1468
|
+
if (!raw) return { ok: false, reason: "no webhook URL configured" };
|
|
1469
|
+
let parsed;
|
|
1470
|
+
try {
|
|
1471
|
+
parsed = new URL(raw);
|
|
1472
|
+
} catch {
|
|
1473
|
+
return { ok: false, reason: `"${raw}" is not a valid URL` };
|
|
1474
|
+
}
|
|
1475
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
1476
|
+
return { ok: false, reason: `unsupported protocol "${parsed.protocol}" \u2014 use http or https` };
|
|
1477
|
+
}
|
|
1478
|
+
if (!parsed.hostname) return { ok: false, reason: "URL has no host" };
|
|
1479
|
+
return { ok: true, url: parsed };
|
|
1480
|
+
}
|
|
1481
|
+
async function resolves(hostname) {
|
|
1482
|
+
if (net.isIP(hostname)) return { ok: true, address: hostname };
|
|
1483
|
+
try {
|
|
1484
|
+
const { address } = await dns.lookup(hostname);
|
|
1485
|
+
return { ok: true, address };
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
if (err.code === "ENOTFOUND" || err.code === "EAI_AGAIN") {
|
|
1488
|
+
return { ok: false, code: err.code, reason: `host "${hostname}" does not resolve` };
|
|
1489
|
+
}
|
|
1490
|
+
return { ok: false, code: err.code, reason: `DNS lookup failed: ${err.message}` };
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
function connects(host, port, timeoutMs) {
|
|
1494
|
+
return new Promise((resolve) => {
|
|
1495
|
+
const socket = net.connect({ host, port });
|
|
1496
|
+
const done = (result) => {
|
|
1497
|
+
socket.removeAllListeners();
|
|
1498
|
+
socket.destroy();
|
|
1499
|
+
resolve(result);
|
|
1500
|
+
};
|
|
1501
|
+
socket.setTimeout(timeoutMs);
|
|
1502
|
+
socket.once("connect", () => done({ ok: true }));
|
|
1503
|
+
socket.once("timeout", () => done({ ok: false, timedOut: true }));
|
|
1504
|
+
socket.once("error", (err) => done({ ok: false, code: err.code, message: err.message }));
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
async function respond(url, method, timeoutMs) {
|
|
1508
|
+
const controller = new AbortController();
|
|
1509
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1510
|
+
try {
|
|
1511
|
+
const res = await fetch(url, { method, signal: controller.signal, redirect: "manual" });
|
|
1512
|
+
clearTimeout(timer);
|
|
1513
|
+
return { responded: true, status: res.status };
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
clearTimeout(timer);
|
|
1516
|
+
return {
|
|
1517
|
+
responded: false,
|
|
1518
|
+
aborted: err.name === "AbortError",
|
|
1519
|
+
code: err.cause?.code ?? err.code,
|
|
1520
|
+
message: err.message
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
async function checkEndpointExists(url, { timeoutMs = 8e3 } = {}) {
|
|
1525
|
+
const name = "Endpoint exists";
|
|
1526
|
+
const parsed = parseEndpoint(url);
|
|
1527
|
+
if (!parsed.ok) return { name, ok: false, exists: false, detail: parsed.reason };
|
|
1528
|
+
const target = parsed.url;
|
|
1529
|
+
const dnsResult = await resolves(target.hostname);
|
|
1530
|
+
if (!dnsResult.ok) {
|
|
1531
|
+
return { name, ok: false, exists: false, detail: `${dnsResult.reason} \u2014 check the URL for a typo` };
|
|
1532
|
+
}
|
|
1533
|
+
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
|
|
1534
|
+
const tcp = await connects(dnsResult.address, port, timeoutMs);
|
|
1535
|
+
if (!tcp.ok && tcp.code === "ECONNREFUSED") {
|
|
1536
|
+
return { name, ok: false, exists: false, detail: `${target.host} refused the connection on port ${port} \u2014 nothing is listening there` };
|
|
1537
|
+
}
|
|
1538
|
+
if (!tcp.ok && (tcp.code === "EHOSTUNREACH" || tcp.code === "ENETUNREACH")) {
|
|
1539
|
+
return { name, ok: false, exists: false, detail: `${target.host} is unreachable (${tcp.code})` };
|
|
1540
|
+
}
|
|
1541
|
+
if (!tcp.ok && tcp.timedOut) {
|
|
1542
|
+
return { name, ok: true, warn: true, exists: void 0, detail: `${target.host}:${port} did not accept a connection within ${timeoutMs}ms \u2014 sending anyway` };
|
|
1543
|
+
}
|
|
1544
|
+
let attempt = await respond(target, "OPTIONS", timeoutMs);
|
|
1545
|
+
if (!attempt.responded) attempt = await respond(target, "HEAD", timeoutMs);
|
|
1546
|
+
if (attempt.responded) {
|
|
1547
|
+
if (attempt.status === 404) {
|
|
1548
|
+
return {
|
|
1549
|
+
name,
|
|
1550
|
+
ok: false,
|
|
1551
|
+
exists: true,
|
|
1552
|
+
detail: `${target.host} is reachable but returned 404 for ${target.pathname} \u2014 the host is right, the path is not`
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
return { name, ok: true, exists: true, detail: `${target.host} responded (HTTP ${attempt.status}) at ${target.pathname}` };
|
|
1556
|
+
}
|
|
1557
|
+
if (attempt.aborted) {
|
|
1558
|
+
return { name, ok: true, warn: true, exists: void 0, detail: `${target.host} did not answer within ${timeoutMs}ms \u2014 sending anyway` };
|
|
1559
|
+
}
|
|
1560
|
+
const code = attempt.code;
|
|
1561
|
+
if (code === "ENOTFOUND") {
|
|
1562
|
+
return { name, ok: false, exists: false, detail: `${target.host} does not resolve` };
|
|
1563
|
+
}
|
|
1564
|
+
if (code === "CERT_HAS_EXPIRED" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "DEPTH_ZERO_SELF_SIGNED_CERT") {
|
|
1565
|
+
return { name, ok: true, warn: true, exists: true, detail: `${target.host} is up but its TLS certificate is not trusted (${code})` };
|
|
1566
|
+
}
|
|
1567
|
+
return { name, ok: true, warn: true, exists: void 0, detail: `could not confirm ${target.host} (${code ?? attempt.message}) \u2014 sending anyway` };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
991
1570
|
// src/core/validate.mjs
|
|
992
1571
|
function checkRoundTrip(body) {
|
|
993
1572
|
let reparsed;
|
|
@@ -1006,20 +1585,45 @@ function checkRoundTrip(body) {
|
|
|
1006
1585
|
detail: `re-serialized body differs at offset ${at.index}: sent ${JSON.stringify(at.a)} vs recomputed ${JSON.stringify(at.b)}`
|
|
1007
1586
|
};
|
|
1008
1587
|
}
|
|
1009
|
-
function checkGate(payload, provider) {
|
|
1010
|
-
const
|
|
1011
|
-
const
|
|
1588
|
+
function checkGate(payload, provider, eventName) {
|
|
1589
|
+
const gate = eventName && provider.gateFor ? provider.gateFor(eventName) : provider.gate;
|
|
1590
|
+
const actual = getPath(payload, gate.path);
|
|
1591
|
+
if (gate.nonEmpty) {
|
|
1592
|
+
const ok2 = Boolean(actual);
|
|
1593
|
+
return {
|
|
1594
|
+
name: "Schema gate",
|
|
1595
|
+
ok: ok2,
|
|
1596
|
+
detail: ok2 ? `${gate.path} is set` : `${gate.path} is empty \u2014 the schema filter requires a non-empty value, so the event would be silently ignored`
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
const ok = actual === gate.value;
|
|
1012
1600
|
return {
|
|
1013
1601
|
name: "Schema gate",
|
|
1014
1602
|
ok,
|
|
1015
|
-
detail: ok ? `${
|
|
1603
|
+
detail: ok ? `${gate.path} = "${actual}"` : `${gate.path} is ${JSON.stringify(actual)} but the schema filter requires "${gate.value}" \u2014 the event would be silently ignored`
|
|
1016
1604
|
};
|
|
1017
1605
|
}
|
|
1606
|
+
function locatePhone(payload) {
|
|
1607
|
+
const candidates = [
|
|
1608
|
+
// cashfree-occ
|
|
1609
|
+
["data.phone", "data.customer.shipping_address.country_code"],
|
|
1610
|
+
["data.customer.shipping_address.phone", "data.customer.shipping_address.country_code"],
|
|
1611
|
+
// razorpay-magic
|
|
1612
|
+
["phone", "customer.Shipping_address.Country_code"],
|
|
1613
|
+
["customer.Shipping_address.Phone", "customer.Shipping_address.Country_code"],
|
|
1614
|
+
// nitro
|
|
1615
|
+
["eventVal.customer.phone", "country"]
|
|
1616
|
+
];
|
|
1617
|
+
for (const [phonePath, regionPath] of candidates) {
|
|
1618
|
+
const phone = getPath(payload, phonePath);
|
|
1619
|
+
if (phone) return { phone, region: getPath(payload, regionPath) };
|
|
1620
|
+
}
|
|
1621
|
+
return { phone: void 0, region: void 0 };
|
|
1622
|
+
}
|
|
1018
1623
|
function checkPhone(payload) {
|
|
1019
|
-
const phone
|
|
1020
|
-
const region = getPath(payload, "data.customer.shipping_address.country_code");
|
|
1624
|
+
const { phone, region } = locatePhone(payload);
|
|
1021
1625
|
if (!phone) {
|
|
1022
|
-
return { name: "Phone", ok: false, detail: "no phone in
|
|
1626
|
+
return { name: "Phone", ok: false, detail: "no phone found in the payload \u2014 the consumer throws BadPayloadError" };
|
|
1023
1627
|
}
|
|
1024
1628
|
const parsed = toE164(phone, region);
|
|
1025
1629
|
return {
|
|
@@ -1033,8 +1637,8 @@ function checkAllowedPhone(payload, config) {
|
|
|
1033
1637
|
if (!allowed.length) {
|
|
1034
1638
|
return { name: "Phone allow-list", ok: true, detail: "no allow-list configured (skipped)", warn: true };
|
|
1035
1639
|
}
|
|
1036
|
-
const phone =
|
|
1037
|
-
const parsed = toE164(phone,
|
|
1640
|
+
const { phone, region } = locatePhone(payload);
|
|
1641
|
+
const parsed = toE164(phone, region);
|
|
1038
1642
|
const normalized = parsed.ok ? parsed.value : phone;
|
|
1039
1643
|
const ok = allowed.some((p) => toE164(p, "IN").value === normalized || p === normalized);
|
|
1040
1644
|
return {
|
|
@@ -1043,8 +1647,8 @@ function checkAllowedPhone(payload, config) {
|
|
|
1043
1647
|
detail: ok ? `${normalized} is an approved test handset` : `${normalized} is NOT in allowedPhones \u2014 refusing to risk messaging a real shopper`
|
|
1044
1648
|
};
|
|
1045
1649
|
}
|
|
1046
|
-
function coverage(payload,
|
|
1047
|
-
return
|
|
1650
|
+
function coverage(payload, fieldMap4) {
|
|
1651
|
+
return fieldMap4.map((f) => {
|
|
1048
1652
|
const value = getPath(payload, f.source);
|
|
1049
1653
|
const present = value !== void 0 && value !== null && value !== "";
|
|
1050
1654
|
return {
|
|
@@ -1058,10 +1662,10 @@ function coverage(payload, fieldMap2) {
|
|
|
1058
1662
|
});
|
|
1059
1663
|
}
|
|
1060
1664
|
async function checkImages(payload, { timeoutMs = 1e4 } = {}) {
|
|
1061
|
-
const items = getPath(payload, "data.line_items") ?? [];
|
|
1062
|
-
const urls = [...new Set(items.map((i) => i?.image_url).filter(Boolean))];
|
|
1665
|
+
const items = getPath(payload, "data.line_items") ?? getPath(payload, "line_items") ?? getPath(payload, "eventVal.line_items") ?? [];
|
|
1666
|
+
const urls = [...new Set(items.map((i) => i?.image_url ?? i?.image).filter(Boolean))];
|
|
1063
1667
|
if (!urls.length) {
|
|
1064
|
-
return [{ name: "Product images", ok:
|
|
1668
|
+
return [{ name: "Product images", ok: true, warn: true, detail: "no line item carries an image \u2014 cart.image will be undefined" }];
|
|
1065
1669
|
}
|
|
1066
1670
|
const results = await Promise.all(
|
|
1067
1671
|
urls.map(async (url) => {
|
|
@@ -1083,10 +1687,12 @@ async function checkImages(payload, { timeoutMs = 1e4 } = {}) {
|
|
|
1083
1687
|
detail: `${r.ok ? "HTTP " + r.status : "unreachable (" + (r.error ?? r.status) + ")"} \u2014 ${shorten(r.url)}`
|
|
1084
1688
|
}));
|
|
1085
1689
|
}
|
|
1086
|
-
async function runAll({ payload, body, provider, config, checkImageUrls = true }) {
|
|
1087
|
-
const checks = [checkGate(payload, provider), checkRoundTrip(body), checkPhone(payload), checkAllowedPhone(payload, config)];
|
|
1690
|
+
async function runAll({ payload, body, provider, config, checkImageUrls = true, checkEndpoint = true, eventName }) {
|
|
1691
|
+
const checks = [checkGate(payload, provider, eventName), checkRoundTrip(body), checkPhone(payload), checkAllowedPhone(payload, config)];
|
|
1088
1692
|
if (checkImageUrls) checks.push(...await checkImages(payload));
|
|
1089
|
-
|
|
1693
|
+
if (checkEndpoint) checks.push(await checkEndpointExists(provider.webhookUrl(config)));
|
|
1694
|
+
const fields = eventName && provider.fieldMapFor ? provider.fieldMapFor(eventName) : provider.fieldMap;
|
|
1695
|
+
const cov = coverage(payload, fields);
|
|
1090
1696
|
const missing = cov.filter((c2) => c2.status === "MISSING");
|
|
1091
1697
|
checks.push({
|
|
1092
1698
|
name: "Required field coverage",
|
|
@@ -1168,18 +1774,21 @@ function loadPayload(file) {
|
|
|
1168
1774
|
}
|
|
1169
1775
|
|
|
1170
1776
|
// src/core/build.mjs
|
|
1171
|
-
function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone }) {
|
|
1777
|
+
function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName }) {
|
|
1172
1778
|
const provider = getProvider(providerId);
|
|
1779
|
+
const event = eventName ?? provider.defaultEvent;
|
|
1173
1780
|
const resolvedPhone = phone ?? cfg.customer.phone;
|
|
1174
1781
|
const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
|
|
1175
1782
|
const payload = provider.buildPayload({
|
|
1176
1783
|
config: cfg,
|
|
1177
1784
|
items,
|
|
1178
1785
|
discount,
|
|
1786
|
+
eventName: event,
|
|
1179
1787
|
phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
|
|
1180
1788
|
});
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1789
|
+
const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
|
|
1790
|
+
const fields = (editable ?? []).map((f) => ({ ...f, value: getPath(payload, f.path) }));
|
|
1791
|
+
return { provider, payload, fields, eventName: event };
|
|
1183
1792
|
}
|
|
1184
1793
|
function applyEdits(payload, fields, edits) {
|
|
1185
1794
|
for (const field of fields) {
|
|
@@ -1190,15 +1799,17 @@ function applyEdits(payload, fields, edits) {
|
|
|
1190
1799
|
}
|
|
1191
1800
|
return payload;
|
|
1192
1801
|
}
|
|
1193
|
-
async function
|
|
1802
|
+
async function buildPayload4({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName }) {
|
|
1194
1803
|
if (!items?.length) throw new ConfigError("no products selected");
|
|
1195
1804
|
const provider = getProvider(providerId);
|
|
1196
1805
|
const resolvedPhone = phone ?? cfg.customer.phone;
|
|
1197
1806
|
const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
|
|
1198
1807
|
if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
|
|
1199
|
-
const
|
|
1808
|
+
const event = eventName ?? provider.defaultEvent;
|
|
1809
|
+
const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event });
|
|
1200
1810
|
const body = JSON.stringify(payload);
|
|
1201
|
-
const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls });
|
|
1811
|
+
const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
|
|
1812
|
+
const summary = provider.summarize ? provider.summarize(payload) : {};
|
|
1202
1813
|
const endpoint = provider.webhookUrl(cfg);
|
|
1203
1814
|
let host = "";
|
|
1204
1815
|
try {
|
|
@@ -1208,17 +1819,18 @@ async function buildPayload2({ cfg, providerId = "cashfree-occ", items, discount
|
|
|
1208
1819
|
}
|
|
1209
1820
|
const meta = {
|
|
1210
1821
|
provider: provider.id,
|
|
1822
|
+
event,
|
|
1211
1823
|
endpoint,
|
|
1212
1824
|
environment: host,
|
|
1213
1825
|
store: cfg.shopify.domain,
|
|
1214
1826
|
items: items.map((i) => ({ title: i.product.title, variant: i.variant?.title, qty: i.quantity, price: i.variant?.price })),
|
|
1215
|
-
totalPrice:
|
|
1216
|
-
currency:
|
|
1827
|
+
totalPrice: summary.total,
|
|
1828
|
+
currency: summary.currency,
|
|
1217
1829
|
valid: report.ok
|
|
1218
1830
|
};
|
|
1219
1831
|
const file = savePayload({ provider: provider.id, payload, body, meta });
|
|
1220
1832
|
audit("payload.built", { provider: provider.id, valid: report.ok, file });
|
|
1221
|
-
return { provider, payload, body, report, file, meta };
|
|
1833
|
+
return { provider, payload, body, report, file, meta, summary };
|
|
1222
1834
|
}
|
|
1223
1835
|
|
|
1224
1836
|
// src/core/send.mjs
|
|
@@ -1230,18 +1842,32 @@ function looksLikeProduction(origin) {
|
|
|
1230
1842
|
}
|
|
1231
1843
|
function buildRequest({ provider, config, body, timestamp }) {
|
|
1232
1844
|
const url = provider.webhookUrl(config);
|
|
1845
|
+
if (!url) {
|
|
1846
|
+
const err = new Error(`no webhook URL configured for ${provider.label}`);
|
|
1847
|
+
err.hint = "set it in Setup \u2192 Webhook (or edit targets in config.json)";
|
|
1848
|
+
throw err;
|
|
1849
|
+
}
|
|
1233
1850
|
const secret = provider.webhookSecret ? provider.webhookSecret(config) : "";
|
|
1234
1851
|
const signed = provider.sign({ body, secret, timestamp });
|
|
1235
1852
|
return { url, headers: signed.headers, signature: signed.signature, timestamp: signed.timestamp, body };
|
|
1236
1853
|
}
|
|
1237
|
-
function
|
|
1238
|
-
|
|
1854
|
+
function shellQuote(value) {
|
|
1855
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1856
|
+
}
|
|
1857
|
+
function toCurl({ url, headers, body, method = "POST" }) {
|
|
1858
|
+
const lines = [`curl --location --request ${method} ${shellQuote(url)} \\`];
|
|
1239
1859
|
for (const [k, v] of Object.entries(headers)) {
|
|
1240
|
-
lines.push(
|
|
1860
|
+
lines.push(`--header ${shellQuote(`${k}: ${v}`)} \\`);
|
|
1241
1861
|
}
|
|
1242
|
-
lines.push(
|
|
1862
|
+
lines.push(`--data ${shellQuote(body)}`);
|
|
1243
1863
|
return lines.join("\n");
|
|
1244
1864
|
}
|
|
1865
|
+
function writeCurlFile(command, name = "last-curl.sh") {
|
|
1866
|
+
fs4.mkdirSync(STATE_DIR, { recursive: true });
|
|
1867
|
+
const file = path4.join(STATE_DIR, name);
|
|
1868
|
+
fs4.writeFileSync(file, command + "\n");
|
|
1869
|
+
return file;
|
|
1870
|
+
}
|
|
1245
1871
|
function writeBodyFile(body, name = "last-body.json") {
|
|
1246
1872
|
fs4.mkdirSync(STATE_DIR, { recursive: true });
|
|
1247
1873
|
const file = path4.join(STATE_DIR, name);
|
|
@@ -1279,28 +1905,42 @@ async function send({ url, headers, body, timeoutMs = 2e4 }) {
|
|
|
1279
1905
|
}
|
|
1280
1906
|
|
|
1281
1907
|
// src/core/dispatch.mjs
|
|
1282
|
-
async function dispatch({ cfg, record, force = false, dryRun = false, revalidate = true }) {
|
|
1908
|
+
async function dispatch({ cfg, record, force = false, dryRun = false, revalidate = true, checkEndpoint = true }) {
|
|
1283
1909
|
const rec = record ?? loadLast();
|
|
1284
1910
|
if (!rec) throw new ForgeError("nothing to send", { hint: "build a payload first" });
|
|
1285
1911
|
const provider = getProvider(rec.provider);
|
|
1286
1912
|
let report = null;
|
|
1287
1913
|
if (revalidate) {
|
|
1288
|
-
report = await runAll({ payload: rec.payload, body: rec.body, provider, config: cfg, checkImageUrls: false });
|
|
1914
|
+
report = await runAll({ payload: rec.payload, body: rec.body, provider, config: cfg, checkImageUrls: false, eventName: rec.meta?.event });
|
|
1289
1915
|
if (!report.ok && !force) {
|
|
1290
|
-
|
|
1291
|
-
hint:
|
|
1916
|
+
const err = new ForgeError("refusing to send a payload that fails pre-flight", {
|
|
1917
|
+
hint: "fix the problems below, or pass --force to send regardless"
|
|
1292
1918
|
});
|
|
1919
|
+
err.failures = report.blocking;
|
|
1920
|
+
throw err;
|
|
1293
1921
|
}
|
|
1294
1922
|
}
|
|
1295
1923
|
const request = buildRequest({ provider, config: cfg, body: rec.body });
|
|
1296
1924
|
if (looksLikeProduction(request.url) && !force) {
|
|
1297
1925
|
throw new ForgeError(`target looks like production: ${request.url}`, { hint: "pass --force if you really mean it" });
|
|
1298
1926
|
}
|
|
1927
|
+
let endpoint = null;
|
|
1928
|
+
if (checkEndpoint && !dryRun) {
|
|
1929
|
+
endpoint = await checkEndpointExists(request.url);
|
|
1930
|
+
if (!endpoint.ok && !force) {
|
|
1931
|
+
const err = new ForgeError(`endpoint check failed: ${endpoint.detail}`, {
|
|
1932
|
+
hint: "fix the URL in Setup \u2192 Webhook, or pass --force to send regardless"
|
|
1933
|
+
});
|
|
1934
|
+
err.failures = [endpoint];
|
|
1935
|
+
throw err;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1299
1938
|
const bodyFile = writeBodyFile(rec.body);
|
|
1300
|
-
const curl = toCurl({ url: request.url, headers: request.headers,
|
|
1301
|
-
|
|
1939
|
+
const curl = toCurl({ url: request.url, headers: request.headers, body: rec.body });
|
|
1940
|
+
const curlFile = writeCurlFile(curl);
|
|
1941
|
+
if (dryRun) return { dryRun: true, request, bodyFile, curl, curlFile, report, provider, endpoint };
|
|
1302
1942
|
const result = await send({ url: request.url, headers: request.headers, body: rec.body });
|
|
1303
|
-
return { ...result, dryRun: false, request, bodyFile, curl, report, provider };
|
|
1943
|
+
return { ...result, dryRun: false, request, bodyFile, curl, curlFile, report, provider, endpoint };
|
|
1304
1944
|
}
|
|
1305
1945
|
function explainStatus(status) {
|
|
1306
1946
|
if (status >= 200 && status < 300) return "accepted \u2014 a 2xx only means it was received; check eventData to confirm the event landed";
|
|
@@ -1364,6 +2004,7 @@ async function copy(text, { allowOsc52 = true } = {}) {
|
|
|
1364
2004
|
// src/ui/screens/Build.jsx
|
|
1365
2005
|
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1366
2006
|
var STEP = {
|
|
2007
|
+
EVENT: "event",
|
|
1367
2008
|
LOADING: "loading",
|
|
1368
2009
|
PICK: "pick",
|
|
1369
2010
|
VARIANT: "variant",
|
|
@@ -1380,7 +2021,10 @@ var STEP = {
|
|
|
1380
2021
|
};
|
|
1381
2022
|
function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
1382
2023
|
const { exit } = useApp();
|
|
1383
|
-
const
|
|
2024
|
+
const provider = getProvider(providerId);
|
|
2025
|
+
const multiEvent = Array.isArray(provider.events) && provider.events.length > 1;
|
|
2026
|
+
const [eventName, setEventName] = useState(provider.defaultEvent);
|
|
2027
|
+
const [step, setStep] = useState(multiEvent ? STEP.EVENT : STEP.LOADING);
|
|
1384
2028
|
const [products, setProducts] = useState([]);
|
|
1385
2029
|
const [chosen, setChosen] = useState([]);
|
|
1386
2030
|
const [cursor, setCursor] = useState(0);
|
|
@@ -1391,18 +2035,20 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1391
2035
|
const [edits, setEdits] = useState({});
|
|
1392
2036
|
const [built, setBuilt] = useState(null);
|
|
1393
2037
|
const [copied, setCopied] = useState(null);
|
|
1394
|
-
const [
|
|
2038
|
+
const [request, setRequest] = useState(null);
|
|
2039
|
+
const [curl, setCurl] = useState("");
|
|
2040
|
+
const [showPayload, setShowPayload] = useState(false);
|
|
1395
2041
|
const [sendResult, setSendResult] = useState(null);
|
|
1396
2042
|
const [error, setError] = useState(null);
|
|
1397
2043
|
useInput3((input, key) => {
|
|
1398
2044
|
if (key.escape) onDone();
|
|
1399
2045
|
if ((step === STEP.RESULT || step === STEP.ERROR) && (key.return || input === "q")) onDone();
|
|
1400
2046
|
if (step === STEP.REPORT && input === "p") setShowPayload((v) => !v);
|
|
1401
|
-
if (step === STEP.REPORT && input === "c" &&
|
|
1402
|
-
|
|
1403
|
-
}
|
|
2047
|
+
if (step === STEP.REPORT && input === "c" && curl) copy(curl).then((r) => setCopied({ ...r, what: "curl" }));
|
|
2048
|
+
if (step === STEP.REPORT && input === "j" && built) copy(built.body).then((r) => setCopied({ ...r, what: "payload JSON" }));
|
|
1404
2049
|
});
|
|
1405
2050
|
useEffect(() => {
|
|
2051
|
+
if (step === STEP.EVENT) return;
|
|
1406
2052
|
let alive = true;
|
|
1407
2053
|
fetchCatalogue(config).then((list) => {
|
|
1408
2054
|
if (!alive) return;
|
|
@@ -1421,7 +2067,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1421
2067
|
return () => {
|
|
1422
2068
|
alive = false;
|
|
1423
2069
|
};
|
|
1424
|
-
}, []);
|
|
2070
|
+
}, [step === STEP.EVENT]);
|
|
1425
2071
|
const advance = (nextItems, nextCursor) => {
|
|
1426
2072
|
if (nextCursor >= chosen.length) {
|
|
1427
2073
|
setItems(nextItems);
|
|
@@ -1465,7 +2111,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1465
2111
|
const onDiscount = (raw) => {
|
|
1466
2112
|
const discount = Math.max(0, Number.parseFloat(raw) || 0);
|
|
1467
2113
|
try {
|
|
1468
|
-
const next = draftPayload({ cfg: config, providerId, items, discount });
|
|
2114
|
+
const next = draftPayload({ cfg: config, providerId, items, discount, eventName });
|
|
1469
2115
|
setDraft({ ...next, discount });
|
|
1470
2116
|
setStep(STEP.MODE);
|
|
1471
2117
|
} catch (err) {
|
|
@@ -1475,10 +2121,20 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1475
2121
|
};
|
|
1476
2122
|
const finalise = (payload) => {
|
|
1477
2123
|
setStep(STEP.BUILDING);
|
|
1478
|
-
|
|
2124
|
+
buildPayload4({ cfg: config, providerId, items, discount: draft.discount, payload, eventName }).then(async (result) => {
|
|
1479
2125
|
setBuilt(result);
|
|
1480
|
-
|
|
1481
|
-
|
|
2126
|
+
let req = null;
|
|
2127
|
+
let command = "";
|
|
2128
|
+
try {
|
|
2129
|
+
req = buildRequest({ provider: result.provider, config, body: result.body });
|
|
2130
|
+
command = toCurl({ url: req.url, headers: req.headers, body: result.body });
|
|
2131
|
+
} catch {
|
|
2132
|
+
req = null;
|
|
2133
|
+
}
|
|
2134
|
+
setRequest(req);
|
|
2135
|
+
setCurl(command);
|
|
2136
|
+
if (config.defaults.copyToClipboard !== false && command) {
|
|
2137
|
+
setCopied({ ...await copy(command), what: "curl" });
|
|
1482
2138
|
}
|
|
1483
2139
|
setStep(STEP.REPORT);
|
|
1484
2140
|
}).catch((err) => {
|
|
@@ -1519,10 +2175,24 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1519
2175
|
Header,
|
|
1520
2176
|
{
|
|
1521
2177
|
title: "Build payload",
|
|
1522
|
-
subtitle: `${metaFor(providerId).label} \xB7 abandoned checkout`,
|
|
2178
|
+
subtitle: `${metaFor(providerId).label} \xB7 ${multiEvent ? eventName : "abandoned checkout"}`,
|
|
1523
2179
|
right: store
|
|
1524
2180
|
}
|
|
1525
2181
|
),
|
|
2182
|
+
step === STEP.EVENT && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
2183
|
+
/* @__PURE__ */ jsx7(Text7, { children: "Which event do you want to send?" }),
|
|
2184
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
|
|
2185
|
+
Select4,
|
|
2186
|
+
{
|
|
2187
|
+
visibleOptionCount: provider.events.length,
|
|
2188
|
+
options: provider.events.map((e) => ({ label: `${e.label.padEnd(20)} ${e.eventName}`, value: e.eventName })),
|
|
2189
|
+
onChange: (value) => {
|
|
2190
|
+
setEventName(value);
|
|
2191
|
+
setStep(STEP.LOADING);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
) })
|
|
2195
|
+
] }),
|
|
1526
2196
|
step === STEP.LOADING && /* @__PURE__ */ jsx7(Spinner, { label: `fetching live products from ${store}\u2026` }),
|
|
1527
2197
|
step === STEP.PICK && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1528
2198
|
/* @__PURE__ */ jsxs7(Text7, { children: [
|
|
@@ -1611,10 +2281,11 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1611
2281
|
/* @__PURE__ */ jsx7(Summary, { built, currency }),
|
|
1612
2282
|
/* @__PURE__ */ jsx7(Section, { title: "Pre-flight", children: /* @__PURE__ */ jsx7(CheckList, { checks: built.report.checks }) }),
|
|
1613
2283
|
/* @__PURE__ */ jsx7(Section, { title: "Field coverage", children: /* @__PURE__ */ jsx7(FieldTable, { coverage: built.report.coverage }) }),
|
|
1614
|
-
/* @__PURE__ */ jsx7(Section, { title: "Request", children: /* @__PURE__ */ jsx7(RequestPreview, {
|
|
2284
|
+
/* @__PURE__ */ jsx7(Section, { title: "Request", children: /* @__PURE__ */ jsx7(RequestPreview, { request, bytes: built.body.length }) }),
|
|
2285
|
+
curl ? /* @__PURE__ */ jsx7(Section, { title: "curl \u2014 paste straight into Postman (Import \u2192 Raw text)", children: /* @__PURE__ */ jsx7(Box7, { borderStyle: "round", borderColor: palette.accent, paddingX: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: curl }) }) }) : null,
|
|
1615
2286
|
showPayload ? /* @__PURE__ */ jsx7(Section, { title: "Payload", children: /* @__PURE__ */ jsx7(Box7, { borderStyle: "round", borderColor: palette.dim, paddingX: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: JSON.stringify(built.payload, null, 2) }) }) }) : null,
|
|
1616
|
-
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: copied?.ok ? /* @__PURE__ */ jsx7(Text7, { color: palette.ok, children: `\u2714
|
|
1617
|
-
/* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "
|
|
2287
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: copied?.ok ? /* @__PURE__ */ jsx7(Text7, { color: palette.ok, children: `\u2714 ${copied.what} copied to clipboard via ${copied.via}` }) : /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: `saved to ${built.file} \xB7 press c to copy the curl` }) }),
|
|
2288
|
+
/* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "c copies the curl \xB7 j copies the payload JSON \xB7 p toggles the full payload" }) }),
|
|
1618
2289
|
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: built.report.ok ? /* @__PURE__ */ jsx7(Alert, { variant: "success", children: "payload is valid \u2014 the event will pass the schema gate and signature check" }) : /* @__PURE__ */ jsx7(Alert, { variant: "error", children: `blocking: ${built.report.blocking.map((b) => b.name).join(", ")}` }) }),
|
|
1619
2290
|
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
1620
2291
|
/* @__PURE__ */ jsx7(Text7, { children: "Send it to the endpoint now? " }),
|
|
@@ -1662,13 +2333,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
|
1662
2333
|
] })
|
|
1663
2334
|
] });
|
|
1664
2335
|
}
|
|
1665
|
-
function RequestPreview({
|
|
1666
|
-
let request = null;
|
|
1667
|
-
try {
|
|
1668
|
-
request = buildRequest({ provider: built.provider, config, body: built.body });
|
|
1669
|
-
} catch {
|
|
1670
|
-
request = null;
|
|
1671
|
-
}
|
|
2336
|
+
function RequestPreview({ request, bytes: bytes2 }) {
|
|
1672
2337
|
if (!request) return /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: " destination not configured" });
|
|
1673
2338
|
return /* @__PURE__ */ jsx7(
|
|
1674
2339
|
KeyValue,
|
|
@@ -1678,7 +2343,7 @@ function RequestPreview({ config, built }) {
|
|
|
1678
2343
|
["POST", truncate(request.url, 56)],
|
|
1679
2344
|
["x-webhook-timestamp", request.timestamp],
|
|
1680
2345
|
["x-webhook-signature", truncate(request.signature, 46)],
|
|
1681
|
-
["body", `${
|
|
2346
|
+
["body", `${bytes2} bytes`]
|
|
1682
2347
|
]
|
|
1683
2348
|
}
|
|
1684
2349
|
);
|
|
@@ -2582,15 +3247,16 @@ async function headless(command, args, config) {
|
|
|
2582
3247
|
return 1;
|
|
2583
3248
|
}
|
|
2584
3249
|
}
|
|
2585
|
-
function requireConfig(config) {
|
|
2586
|
-
if (isConfigured(config)) return null;
|
|
2587
|
-
const missing = configStatus(config).filter((r) => !r.ok).map((r) => r.key);
|
|
3250
|
+
function requireConfig(config, providerId = "cashfree-occ") {
|
|
3251
|
+
if (isConfigured(config, providerId)) return null;
|
|
3252
|
+
const missing = configStatus(config, providerId).filter((r) => !r.ok).map((r) => r.key);
|
|
2588
3253
|
log.fail(`configuration incomplete: ${missing.join(", ")}`);
|
|
2589
3254
|
log.dim(`run \`hookwright configure\` or edit ${config._path}`);
|
|
2590
3255
|
return 1;
|
|
2591
3256
|
}
|
|
2592
3257
|
async function cmdBuild(args, config) {
|
|
2593
|
-
const
|
|
3258
|
+
const providerId = typeof args.flags.provider === "string" ? args.flags.provider : "cashfree-occ";
|
|
3259
|
+
const bad = requireConfig(config, providerId);
|
|
2594
3260
|
if (bad) return bad;
|
|
2595
3261
|
const count = Number.parseInt(args.flags.items ?? "1", 10) || 1;
|
|
2596
3262
|
const discount = Number.parseFloat(args.flags.discount ?? "0") || 0;
|
|
@@ -2604,8 +3270,10 @@ async function cmdBuild(args, config) {
|
|
|
2604
3270
|
}
|
|
2605
3271
|
const items = autoSelect(products, count);
|
|
2606
3272
|
log.ok(`${items.length} product(s): ${items.map((i) => i.product.title).join(", ")}`);
|
|
2607
|
-
const built = await
|
|
3273
|
+
const built = await buildPayload4({
|
|
2608
3274
|
cfg: config,
|
|
3275
|
+
providerId,
|
|
3276
|
+
eventName: typeof args.flags.event === "string" ? args.flags.event : void 0,
|
|
2609
3277
|
items,
|
|
2610
3278
|
discount,
|
|
2611
3279
|
phone: typeof args.flags.phone === "string" ? args.flags.phone : void 0,
|
|
@@ -2622,8 +3290,18 @@ async function cmdBuild(args, config) {
|
|
|
2622
3290
|
}
|
|
2623
3291
|
}
|
|
2624
3292
|
if (args.flags.copy !== false && !args.flags.json) {
|
|
2625
|
-
|
|
2626
|
-
|
|
3293
|
+
let clip = built.body;
|
|
3294
|
+
let what = "payload JSON";
|
|
3295
|
+
try {
|
|
3296
|
+
const request = buildRequest({ provider: built.provider, config, body: built.body });
|
|
3297
|
+
clip = toCurl({ url: request.url, headers: request.headers, body: built.body });
|
|
3298
|
+
what = "curl";
|
|
3299
|
+
} catch (err) {
|
|
3300
|
+
log.warn(`${err.message} \u2014 copying the payload JSON instead`);
|
|
3301
|
+
if (err.hint) log.dim(err.hint);
|
|
3302
|
+
}
|
|
3303
|
+
const result = await copy(clip);
|
|
3304
|
+
if (result.ok) log.ok(`${what} copied to clipboard via ${result.via}`);
|
|
2627
3305
|
else log.dim(`clipboard unavailable \u2014 install xclip, xsel or wl-copy to enable copying`);
|
|
2628
3306
|
}
|
|
2629
3307
|
if (!built.report.ok && !args.flags.force) {
|
|
@@ -2651,7 +3329,8 @@ async function cmdSend(args, config, built) {
|
|
|
2651
3329
|
cfg: config,
|
|
2652
3330
|
record,
|
|
2653
3331
|
force: Boolean(args.flags.force),
|
|
2654
|
-
dryRun: Boolean(args.flags["dry-run"])
|
|
3332
|
+
dryRun: Boolean(args.flags["dry-run"]),
|
|
3333
|
+
checkEndpoint: args.flags["endpoint-check"] !== false
|
|
2655
3334
|
});
|
|
2656
3335
|
if (result.dryRun) {
|
|
2657
3336
|
log.blank();
|
|
@@ -2749,14 +3428,15 @@ function cmdClear(args, config) {
|
|
|
2749
3428
|
return 0;
|
|
2750
3429
|
}
|
|
2751
3430
|
function printReport(built) {
|
|
2752
|
-
const
|
|
3431
|
+
const s = built.summary ?? {};
|
|
2753
3432
|
log.blank();
|
|
2754
3433
|
box("Payload", [
|
|
2755
|
-
`
|
|
2756
|
-
`
|
|
2757
|
-
`
|
|
2758
|
-
`
|
|
2759
|
-
`
|
|
3434
|
+
`provider ${built.provider.label}`,
|
|
3435
|
+
`event ${s.event ?? "\u2014"}`,
|
|
3436
|
+
`store ${s.store ?? "\u2014"}`,
|
|
3437
|
+
`customer ${s.customer ?? "\u2014"}`,
|
|
3438
|
+
`items ${s.items ?? "\u2014"}`,
|
|
3439
|
+
`total ${s.total ?? "\u2014"} ${s.currency ?? ""}${s.extra ? " (" + s.extra + ")" : ""}`,
|
|
2760
3440
|
`saved ${built.file}`
|
|
2761
3441
|
], c.green);
|
|
2762
3442
|
log.blank();
|
|
@@ -2796,6 +3476,9 @@ ${c.bold("COMMANDS")}
|
|
|
2796
3476
|
help show this message
|
|
2797
3477
|
|
|
2798
3478
|
${c.bold("BUILD OPTIONS")}
|
|
3479
|
+
--provider <id> cashfree-occ | razorpay-magic | nitro (default cashfree-occ)
|
|
3480
|
+
--event <name> nitro only: view \xB7 category_view \xB7 product_view \xB7 addtocart
|
|
3481
|
+
removefromcart \xB7 checkout \xB7 orders/create \xB7 orders/updated
|
|
2799
3482
|
--items <n> how many products to put in the cart (default 1)
|
|
2800
3483
|
--search <text> only consider products matching a title
|
|
2801
3484
|
--discount <n> cart discount in major currency units (default 0)
|
|
@@ -2811,6 +3494,7 @@ ${c.bold("SEND OPTIONS")}
|
|
|
2811
3494
|
--file <path> send a specific saved payload
|
|
2812
3495
|
--dry-run print the request without sending
|
|
2813
3496
|
--force send even if pre-flight fails or target looks like prod
|
|
3497
|
+
--no-endpoint-check skip verifying the destination exists before sending
|
|
2814
3498
|
|
|
2815
3499
|
${c.bold("CLEAR OPTIONS")}
|
|
2816
3500
|
--payloads delete saved payloads
|
|
@@ -2882,6 +3566,9 @@ async function main() {
|
|
|
2882
3566
|
main().catch((err) => {
|
|
2883
3567
|
console.error(c.red(`
|
|
2884
3568
|
${err.message}`));
|
|
3569
|
+
for (const failure of err.failures ?? []) {
|
|
3570
|
+
console.error(` ${c.red(sym.cross)} ${c.bold(failure.name)}: ${failure.detail}`);
|
|
3571
|
+
}
|
|
2885
3572
|
if (err.hint) console.error(c.yellow(` ${err.hint}`));
|
|
2886
3573
|
process.exitCode = 1;
|
|
2887
3574
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookwright",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Build real, signed e-commerce webhooks from a live Shopify catalogue
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Build real, signed e-commerce webhooks from a live Shopify catalogue — interactive terminal UI, no backend",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"webhook",
|
|
7
7
|
"shopify",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"react": "^19.2.8"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
+
"curl-to-postmanv2": "^1.8.7",
|
|
42
43
|
"esbuild": "^0.28.2"
|
|
43
44
|
}
|
|
44
45
|
}
|