@drawbridge/drawbridge-utils 0.0.107 → 0.0.108
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/dist/connections/index.cjs +114 -39
- package/dist/connections/index.d.cts +181 -40
- package/dist/connections/index.d.ts +181 -40
- package/dist/connections/index.js +114 -39
- package/package.json +1 -1
|
@@ -76,14 +76,49 @@ var HOOKS = Object.freeze({
|
|
|
76
76
|
"cleanup"
|
|
77
77
|
]),
|
|
78
78
|
// Receiving from the vendor.
|
|
79
|
+
//
|
|
80
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
81
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
82
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
83
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
84
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
85
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
86
|
+
// ever knowing.
|
|
87
|
+
//
|
|
88
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
89
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
90
|
+
// into duplicate deliveries.
|
|
79
91
|
inbound: Object.freeze([
|
|
80
|
-
// Prove the request came from the vendor
|
|
81
|
-
//
|
|
92
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
93
|
+
// carries. One hook rather than two because for some vendors they are
|
|
94
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
95
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
96
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
97
|
+
//
|
|
98
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
99
|
+
// this returned, which means nothing downstream can act on unverified
|
|
100
|
+
// data even by mistake.
|
|
101
|
+
//
|
|
102
|
+
// It is also where a request gets refused for any other reason — an event
|
|
103
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
104
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
82
105
|
"verify",
|
|
83
|
-
// Name the event, from wherever this vendor puts it.
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
"
|
|
106
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
107
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
108
|
+
// JWT-bodied vendor.
|
|
109
|
+
"event",
|
|
110
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
111
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
112
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
113
|
+
// carriers require) returns that too.
|
|
114
|
+
"receive",
|
|
115
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
116
|
+
//
|
|
117
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
118
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
119
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
120
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
121
|
+
"process"
|
|
87
122
|
]),
|
|
88
123
|
// Vendor data a campaign draws on. Named for what every store platform has,
|
|
89
124
|
// not for what Shopify calls it: Shopify says discounts, Stripe says coupons
|
|
@@ -309,8 +344,9 @@ var klaviyo_default2 = {
|
|
|
309
344
|
"catalog.prices": false,
|
|
310
345
|
"catalog.products": false,
|
|
311
346
|
"catalog.promotions": false,
|
|
312
|
-
"inbound.
|
|
313
|
-
"inbound.
|
|
347
|
+
"inbound.event": false,
|
|
348
|
+
"inbound.process": false,
|
|
349
|
+
"inbound.receive": false,
|
|
314
350
|
"inbound.verify": false,
|
|
315
351
|
"lifecycle.cleanup": false,
|
|
316
352
|
"lifecycle.register": false,
|
|
@@ -380,8 +416,9 @@ var mailchimp_default2 = {
|
|
|
380
416
|
"catalog.prices": false,
|
|
381
417
|
"catalog.products": false,
|
|
382
418
|
"catalog.promotions": false,
|
|
383
|
-
"inbound.
|
|
384
|
-
"inbound.
|
|
419
|
+
"inbound.event": false,
|
|
420
|
+
"inbound.process": false,
|
|
421
|
+
"inbound.receive": false,
|
|
385
422
|
"inbound.verify": false,
|
|
386
423
|
"lifecycle.cleanup": false,
|
|
387
424
|
"lifecycle.register": false,
|
|
@@ -415,7 +452,42 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
415
452
|
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
416
453
|
</svg>`;
|
|
417
454
|
|
|
455
|
+
// lib/connections/inbound.js
|
|
456
|
+
var import_node_crypto2 = require("crypto");
|
|
457
|
+
var verifySignature = ({ body, descriptor, headers }) => {
|
|
458
|
+
const provided = headers[descriptor.headers.signature];
|
|
459
|
+
if (!provided) {
|
|
460
|
+
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
461
|
+
}
|
|
462
|
+
const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
|
|
463
|
+
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
464
|
+
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
465
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
466
|
+
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
467
|
+
}
|
|
468
|
+
return JSON.parse(body.toString());
|
|
469
|
+
};
|
|
470
|
+
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
471
|
+
|
|
418
472
|
// lib/connections/shopify.js
|
|
473
|
+
var inbound = {
|
|
474
|
+
headers: {
|
|
475
|
+
event: "x-shopify-topic",
|
|
476
|
+
id: "x-shopify-webhook-id",
|
|
477
|
+
shop: "x-shopify-shop-domain",
|
|
478
|
+
signature: "x-shopify-hmac-sha256"
|
|
479
|
+
},
|
|
480
|
+
signature: {
|
|
481
|
+
algorithm: "sha256",
|
|
482
|
+
encoding: "base64",
|
|
483
|
+
secret: "SHOPIFY_API_SECRET"
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
|
|
487
|
+
"customers/data_request",
|
|
488
|
+
"customers/redact",
|
|
489
|
+
"shop/redact"
|
|
490
|
+
]);
|
|
419
491
|
var shopify_default2 = {
|
|
420
492
|
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
421
493
|
// sees a consent screen we sent them to -- they start at the App Store, and
|
|
@@ -460,30 +532,31 @@ var shopify_default2 = {
|
|
|
460
532
|
}
|
|
461
533
|
],
|
|
462
534
|
group: "ecommerce",
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
535
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
536
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
537
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
538
|
+
// action: /events buffers whatever arrives with the shop domain stamped on;
|
|
539
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
540
|
+
// is a legal deadline rather than a retry.
|
|
541
|
+
hooks: {
|
|
542
|
+
"inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
543
|
+
"inbound.receive": ({ action, event, headers, payload }) => {
|
|
544
|
+
if (action === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
545
|
+
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
549
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
550
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
551
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
552
|
+
data: action === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
|
|
553
|
+
provider: { id: headers[inbound.headers.id] || null }
|
|
554
|
+
};
|
|
479
555
|
},
|
|
480
|
-
|
|
481
|
-
algorithm: "sha256",
|
|
482
|
-
encoding: "base64",
|
|
483
|
-
secret: "SHOPIFY_API_SECRET"
|
|
484
|
-
}
|
|
556
|
+
"inbound.verify": (args) => verifySignature({ ...args, descriptor: inbound })
|
|
485
557
|
},
|
|
486
558
|
icon: shopify_default,
|
|
559
|
+
inbound,
|
|
487
560
|
label: "shopify",
|
|
488
561
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
489
562
|
// exists and the app is fully configured. Requiring all four means it can
|
|
@@ -512,8 +585,9 @@ var shopify_default2 = {
|
|
|
512
585
|
"catalog.prices": false,
|
|
513
586
|
"catalog.products": true,
|
|
514
587
|
"catalog.promotions": true,
|
|
515
|
-
"inbound.
|
|
516
|
-
"inbound.
|
|
588
|
+
"inbound.event": true,
|
|
589
|
+
"inbound.process": true,
|
|
590
|
+
"inbound.receive": true,
|
|
517
591
|
"inbound.verify": true,
|
|
518
592
|
"lifecycle.cleanup": true,
|
|
519
593
|
"lifecycle.register": true,
|
|
@@ -673,8 +747,9 @@ var webhook_default = {
|
|
|
673
747
|
"catalog.prices": false,
|
|
674
748
|
"catalog.products": false,
|
|
675
749
|
"catalog.promotions": false,
|
|
676
|
-
"inbound.
|
|
677
|
-
"inbound.
|
|
750
|
+
"inbound.event": false,
|
|
751
|
+
"inbound.process": false,
|
|
752
|
+
"inbound.receive": false,
|
|
678
753
|
"inbound.verify": false,
|
|
679
754
|
"lifecycle.cleanup": false,
|
|
680
755
|
"lifecycle.register": false,
|
|
@@ -764,8 +839,8 @@ var build = (manifest) => {
|
|
|
764
839
|
}
|
|
765
840
|
}
|
|
766
841
|
}
|
|
767
|
-
if (((_d = manifest.supports) == null ? void 0 : _d["inbound.
|
|
768
|
-
throw new Error(manifest.slug + " supports inbound.
|
|
842
|
+
if (((_d = manifest.supports) == null ? void 0 : _d["inbound.event"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.event)) {
|
|
843
|
+
throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
|
|
769
844
|
}
|
|
770
845
|
if (((_g = manifest.supports) == null ? void 0 : _g["inbound.verify"]) && !((_i = (_h = manifest.inbound) == null ? void 0 : _h.headers) == null ? void 0 : _i.signature)) {
|
|
771
846
|
throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
|
|
@@ -880,7 +955,7 @@ var runHook = async (slug, name, args = {}) => {
|
|
|
880
955
|
try {
|
|
881
956
|
return { outcome: OUTCOMES.answered, result: await hook(args) };
|
|
882
957
|
} catch (error) {
|
|
883
|
-
return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed };
|
|
958
|
+
return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
|
|
884
959
|
}
|
|
885
960
|
};
|
|
886
961
|
var stepQueues = (env = {}) => Object.fromEntries(
|
|
@@ -946,7 +1021,7 @@ var projectConnection = (record) => {
|
|
|
946
1021
|
var resolveConnection = (item, data) => {
|
|
947
1022
|
if (!item) return item;
|
|
948
1023
|
return Object.fromEntries(
|
|
949
|
-
Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
|
|
1024
|
+
Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
|
|
950
1025
|
key,
|
|
951
1026
|
typeof value === "function" ? value(data) : value
|
|
952
1027
|
])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { consentUrl, exchange, pkcePair, refresh } from './oauth.cjs';
|
|
2
|
-
import 'node:crypto';
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
|
|
4
4
|
// THE HOOK VOCABULARY. Closed, and every connection answers all of it.
|
|
5
5
|
//
|
|
@@ -48,14 +48,49 @@ const HOOKS = Object.freeze({
|
|
|
48
48
|
]),
|
|
49
49
|
|
|
50
50
|
// Receiving from the vendor.
|
|
51
|
+
//
|
|
52
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
53
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
54
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
55
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
56
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
57
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
58
|
+
// ever knowing.
|
|
59
|
+
//
|
|
60
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
61
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
62
|
+
// into duplicate deliveries.
|
|
51
63
|
inbound : Object.freeze([
|
|
52
|
-
// Prove the request came from the vendor
|
|
53
|
-
//
|
|
64
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
65
|
+
// carries. One hook rather than two because for some vendors they are
|
|
66
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
67
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
68
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
69
|
+
//
|
|
70
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
71
|
+
// this returned, which means nothing downstream can act on unverified
|
|
72
|
+
// data even by mistake.
|
|
73
|
+
//
|
|
74
|
+
// It is also where a request gets refused for any other reason — an event
|
|
75
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
76
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
54
77
|
'verify',
|
|
55
|
-
// Name the event, from wherever this vendor puts it.
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
'
|
|
78
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
79
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
80
|
+
// JWT-bodied vendor.
|
|
81
|
+
'event',
|
|
82
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
83
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
84
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
85
|
+
// carriers require) returns that too.
|
|
86
|
+
'receive',
|
|
87
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
88
|
+
//
|
|
89
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
90
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
91
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
92
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
93
|
+
'process'
|
|
59
94
|
]),
|
|
60
95
|
|
|
61
96
|
// Vendor data a campaign draws on. Named for what every store platform has,
|
|
@@ -261,8 +296,9 @@ var klaviyo = {
|
|
|
261
296
|
'catalog.prices' : false,
|
|
262
297
|
'catalog.products' : false,
|
|
263
298
|
'catalog.promotions' : false,
|
|
264
|
-
'inbound.
|
|
265
|
-
'inbound.
|
|
299
|
+
'inbound.event' : false,
|
|
300
|
+
'inbound.process' : false,
|
|
301
|
+
'inbound.receive' : false,
|
|
266
302
|
'inbound.verify' : false,
|
|
267
303
|
'lifecycle.cleanup' : false,
|
|
268
304
|
'lifecycle.register' : false,
|
|
@@ -342,8 +378,9 @@ var mailchimp = {
|
|
|
342
378
|
'catalog.prices' : false,
|
|
343
379
|
'catalog.products' : false,
|
|
344
380
|
'catalog.promotions' : false,
|
|
345
|
-
'inbound.
|
|
346
|
-
'inbound.
|
|
381
|
+
'inbound.event' : false,
|
|
382
|
+
'inbound.process' : false,
|
|
383
|
+
'inbound.receive' : false,
|
|
347
384
|
'inbound.verify' : false,
|
|
348
385
|
'lifecycle.cleanup' : false,
|
|
349
386
|
'lifecycle.register' : false,
|
|
@@ -382,6 +419,96 @@ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
382
419
|
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
383
420
|
</svg>`;
|
|
384
421
|
|
|
422
|
+
// THE SHARED HALF OF inbound.verify.
|
|
423
|
+
//
|
|
424
|
+
// A vendor whose scheme is "hash the raw body with a shared secret and compare,
|
|
425
|
+
// constant-time, against a header" declares that shape as `inbound.signature`
|
|
426
|
+
// data on its manifest (algorithm, encoding, the env var naming the secret) and
|
|
427
|
+
// wires this straight in as its hook — Shopify does exactly that.
|
|
428
|
+
//
|
|
429
|
+
// A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
|
|
430
|
+
// rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
|
|
431
|
+
// own inbound.verify instead. That is why verify is a hook and not config: this
|
|
432
|
+
// file covers the common case, not the contract.
|
|
433
|
+
const verifySignature = ({ body, descriptor, headers }) => {
|
|
434
|
+
|
|
435
|
+
const provided = headers[ descriptor.headers.signature ];
|
|
436
|
+
|
|
437
|
+
if( ! provided ){
|
|
438
|
+
|
|
439
|
+
throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
|
|
440
|
+
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
|
|
444
|
+
.update( body )
|
|
445
|
+
.digest( descriptor.signature.encoding );
|
|
446
|
+
|
|
447
|
+
// Buffers of different lengths crash timingSafeEqual rather than compare
|
|
448
|
+
// false — decided here, before the length itself becomes a timing signal.
|
|
449
|
+
const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
|
|
450
|
+
const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
|
|
451
|
+
|
|
452
|
+
if(
|
|
453
|
+
digestBuffer.length !== providedBuffer.length ||
|
|
454
|
+
! timingSafeEqual( digestBuffer, providedBuffer )
|
|
455
|
+
){
|
|
456
|
+
|
|
457
|
+
throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
|
|
458
|
+
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The proof and the parse happen together on purpose. Nothing downstream of
|
|
462
|
+
// inbound.verify ever sees the raw bytes, which is what makes acting on
|
|
463
|
+
// unverified data impossible rather than merely discouraged.
|
|
464
|
+
return JSON.parse( body.toString() );
|
|
465
|
+
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
|
|
469
|
+
const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
|
|
470
|
+
|
|
471
|
+
// WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
|
|
472
|
+
// about someone else's product, so they belong beside the rest of the vendor
|
|
473
|
+
// rather than as string literals in a route — which is where they were, and is
|
|
474
|
+
// why a second inbound vendor meant a second route file.
|
|
475
|
+
//
|
|
476
|
+
// Pulled out to a const (rather than written inline under `inbound:` below) so
|
|
477
|
+
// the hooks further down can reference the SAME object the manifest publishes,
|
|
478
|
+
// instead of a second copy that could drift from it.
|
|
479
|
+
//
|
|
480
|
+
// `signature` describes an HMAC scheme the shared verifier can run: hash the
|
|
481
|
+
// raw body with the named secret and compare, constant-time, against the
|
|
482
|
+
// header. Vendors whose scheme is not that shape declare no signature block and
|
|
483
|
+
// implement inbound.verify themselves — a JWT-bodied vendor (Kinde) verifies
|
|
484
|
+
// against a JWKS and returns the decoded claims, and Twilio signs the
|
|
485
|
+
// registered URL rather than the body. That is why verify is a hook and not
|
|
486
|
+
// config.
|
|
487
|
+
const inbound = {
|
|
488
|
+
headers : {
|
|
489
|
+
event : 'x-shopify-topic',
|
|
490
|
+
id : 'x-shopify-webhook-id',
|
|
491
|
+
shop : 'x-shopify-shop-domain',
|
|
492
|
+
signature : 'x-shopify-hmac-sha256'
|
|
493
|
+
},
|
|
494
|
+
signature : {
|
|
495
|
+
algorithm : 'sha256',
|
|
496
|
+
encoding : 'base64',
|
|
497
|
+
secret : 'SHOPIFY_API_SECRET'
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Shopify's own GDPR deadline, not ours — these three are the only topics
|
|
502
|
+
// registered under `compliance_topics` in the app toml, and Shopify expects an
|
|
503
|
+
// answer even for a shop that has already uninstalled. Anything else on this
|
|
504
|
+
// action is refused here rather than buffered, the same way a bad signature is:
|
|
505
|
+
// it is a request that should never have arrived.
|
|
506
|
+
const COMPLIANCE_TOPICS = new Set([
|
|
507
|
+
'customers/data_request',
|
|
508
|
+
'customers/redact',
|
|
509
|
+
'shop/redact'
|
|
510
|
+
]);
|
|
511
|
+
|
|
385
512
|
// Shopify — installed from the App Store, never connected with keys.
|
|
386
513
|
var shopify = {
|
|
387
514
|
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
@@ -427,30 +554,38 @@ var shopify = {
|
|
|
427
554
|
}
|
|
428
555
|
],
|
|
429
556
|
group : 'ecommerce',
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
557
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
558
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
559
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
560
|
+
// action: /events buffers whatever arrives with the shop domain stamped on;
|
|
561
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
562
|
+
// is a legal deadline rather than a retry.
|
|
563
|
+
hooks : {
|
|
564
|
+
'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
565
|
+
'inbound.receive' : ({ action, event, headers, payload }) => {
|
|
566
|
+
|
|
567
|
+
if( action === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
568
|
+
|
|
569
|
+
throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
|
|
570
|
+
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
575
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
576
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
577
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
578
|
+
data : action === 'compliance'
|
|
579
|
+
? payload
|
|
580
|
+
: { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
|
|
581
|
+
provider : { id : headers[ inbound.headers.id ] || null }
|
|
582
|
+
};
|
|
583
|
+
|
|
446
584
|
},
|
|
447
|
-
|
|
448
|
-
algorithm : 'sha256',
|
|
449
|
-
encoding : 'base64',
|
|
450
|
-
secret : 'SHOPIFY_API_SECRET'
|
|
451
|
-
}
|
|
585
|
+
'inbound.verify' : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
452
586
|
},
|
|
453
587
|
icon: icon$1,
|
|
588
|
+
inbound,
|
|
454
589
|
label : 'shopify',
|
|
455
590
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
456
591
|
// exists and the app is fully configured. Requiring all four means it can
|
|
@@ -479,8 +614,9 @@ var shopify = {
|
|
|
479
614
|
'catalog.prices' : false,
|
|
480
615
|
'catalog.products' : true,
|
|
481
616
|
'catalog.promotions' : true,
|
|
482
|
-
'inbound.
|
|
483
|
-
'inbound.
|
|
617
|
+
'inbound.event' : true,
|
|
618
|
+
'inbound.process' : true,
|
|
619
|
+
'inbound.receive' : true,
|
|
484
620
|
'inbound.verify' : true,
|
|
485
621
|
'lifecycle.cleanup' : true,
|
|
486
622
|
'lifecycle.register' : true,
|
|
@@ -647,8 +783,9 @@ var webhook = {
|
|
|
647
783
|
'catalog.prices' : false,
|
|
648
784
|
'catalog.products' : false,
|
|
649
785
|
'catalog.promotions' : false,
|
|
650
|
-
'inbound.
|
|
651
|
-
'inbound.
|
|
786
|
+
'inbound.event' : false,
|
|
787
|
+
'inbound.process' : false,
|
|
788
|
+
'inbound.receive' : false,
|
|
652
789
|
'inbound.verify' : false,
|
|
653
790
|
'lifecycle.cleanup' : false,
|
|
654
791
|
'lifecycle.register' : false,
|
|
@@ -835,9 +972,9 @@ const build = ( manifest ) => {
|
|
|
835
972
|
// A vendor that receives from the outside must say where it puts the event
|
|
836
973
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
837
974
|
// is a request accepted and dropped rather than an error.
|
|
838
|
-
if( manifest.supports?.[ 'inbound.
|
|
975
|
+
if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
|
|
839
976
|
|
|
840
|
-
throw new Error( manifest.slug + ' supports inbound.
|
|
977
|
+
throw new Error( manifest.slug + ' supports inbound.event but declares no inbound.headers.event' );
|
|
841
978
|
|
|
842
979
|
}
|
|
843
980
|
|
|
@@ -1090,7 +1227,11 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
1090
1227
|
|
|
1091
1228
|
} catch ( error ) {
|
|
1092
1229
|
|
|
1093
|
-
|
|
1230
|
+
// A hook throws to REJECT, not just to report failure — inbound.verify
|
|
1231
|
+
// on a forged signature, inbound.receive on a topic outside the vendor's
|
|
1232
|
+
// declared set. The status rides along so a caller can answer 401 rather
|
|
1233
|
+
// than always folding a hook's own rejection into a 500.
|
|
1234
|
+
return { error : error?.message || 'failed', outcome : OUTCOMES.failed, status : error?.status || null };
|
|
1094
1235
|
|
|
1095
1236
|
}
|
|
1096
1237
|
|
|
@@ -1216,7 +1357,7 @@ const resolveConnection = ( item, data ) => {
|
|
|
1216
1357
|
|
|
1217
1358
|
return Object.fromEntries(
|
|
1218
1359
|
Object.entries( item )
|
|
1219
|
-
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1360
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1220
1361
|
.map( ( [ key, value ] ) => [
|
|
1221
1362
|
key,
|
|
1222
1363
|
( typeof value === 'function' ? value( data ) : value )
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { consentUrl, exchange, pkcePair, refresh } from './oauth.js';
|
|
2
|
-
import 'node:crypto';
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
|
|
4
4
|
// THE HOOK VOCABULARY. Closed, and every connection answers all of it.
|
|
5
5
|
//
|
|
@@ -48,14 +48,49 @@ const HOOKS = Object.freeze({
|
|
|
48
48
|
]),
|
|
49
49
|
|
|
50
50
|
// Receiving from the vendor.
|
|
51
|
+
//
|
|
52
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
53
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
54
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
55
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
56
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
57
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
58
|
+
// ever knowing.
|
|
59
|
+
//
|
|
60
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
61
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
62
|
+
// into duplicate deliveries.
|
|
51
63
|
inbound : Object.freeze([
|
|
52
|
-
// Prove the request came from the vendor
|
|
53
|
-
//
|
|
64
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
65
|
+
// carries. One hook rather than two because for some vendors they are
|
|
66
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
67
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
68
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
69
|
+
//
|
|
70
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
71
|
+
// this returned, which means nothing downstream can act on unverified
|
|
72
|
+
// data even by mistake.
|
|
73
|
+
//
|
|
74
|
+
// It is also where a request gets refused for any other reason — an event
|
|
75
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
76
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
54
77
|
'verify',
|
|
55
|
-
// Name the event, from wherever this vendor puts it.
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
'
|
|
78
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
79
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
80
|
+
// JWT-bodied vendor.
|
|
81
|
+
'event',
|
|
82
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
83
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
84
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
85
|
+
// carriers require) returns that too.
|
|
86
|
+
'receive',
|
|
87
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
88
|
+
//
|
|
89
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
90
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
91
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
92
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
93
|
+
'process'
|
|
59
94
|
]),
|
|
60
95
|
|
|
61
96
|
// Vendor data a campaign draws on. Named for what every store platform has,
|
|
@@ -261,8 +296,9 @@ var klaviyo = {
|
|
|
261
296
|
'catalog.prices' : false,
|
|
262
297
|
'catalog.products' : false,
|
|
263
298
|
'catalog.promotions' : false,
|
|
264
|
-
'inbound.
|
|
265
|
-
'inbound.
|
|
299
|
+
'inbound.event' : false,
|
|
300
|
+
'inbound.process' : false,
|
|
301
|
+
'inbound.receive' : false,
|
|
266
302
|
'inbound.verify' : false,
|
|
267
303
|
'lifecycle.cleanup' : false,
|
|
268
304
|
'lifecycle.register' : false,
|
|
@@ -342,8 +378,9 @@ var mailchimp = {
|
|
|
342
378
|
'catalog.prices' : false,
|
|
343
379
|
'catalog.products' : false,
|
|
344
380
|
'catalog.promotions' : false,
|
|
345
|
-
'inbound.
|
|
346
|
-
'inbound.
|
|
381
|
+
'inbound.event' : false,
|
|
382
|
+
'inbound.process' : false,
|
|
383
|
+
'inbound.receive' : false,
|
|
347
384
|
'inbound.verify' : false,
|
|
348
385
|
'lifecycle.cleanup' : false,
|
|
349
386
|
'lifecycle.register' : false,
|
|
@@ -382,6 +419,96 @@ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
382
419
|
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
383
420
|
</svg>`;
|
|
384
421
|
|
|
422
|
+
// THE SHARED HALF OF inbound.verify.
|
|
423
|
+
//
|
|
424
|
+
// A vendor whose scheme is "hash the raw body with a shared secret and compare,
|
|
425
|
+
// constant-time, against a header" declares that shape as `inbound.signature`
|
|
426
|
+
// data on its manifest (algorithm, encoding, the env var naming the secret) and
|
|
427
|
+
// wires this straight in as its hook — Shopify does exactly that.
|
|
428
|
+
//
|
|
429
|
+
// A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
|
|
430
|
+
// rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
|
|
431
|
+
// own inbound.verify instead. That is why verify is a hook and not config: this
|
|
432
|
+
// file covers the common case, not the contract.
|
|
433
|
+
const verifySignature = ({ body, descriptor, headers }) => {
|
|
434
|
+
|
|
435
|
+
const provided = headers[ descriptor.headers.signature ];
|
|
436
|
+
|
|
437
|
+
if( ! provided ){
|
|
438
|
+
|
|
439
|
+
throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
|
|
440
|
+
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
|
|
444
|
+
.update( body )
|
|
445
|
+
.digest( descriptor.signature.encoding );
|
|
446
|
+
|
|
447
|
+
// Buffers of different lengths crash timingSafeEqual rather than compare
|
|
448
|
+
// false — decided here, before the length itself becomes a timing signal.
|
|
449
|
+
const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
|
|
450
|
+
const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
|
|
451
|
+
|
|
452
|
+
if(
|
|
453
|
+
digestBuffer.length !== providedBuffer.length ||
|
|
454
|
+
! timingSafeEqual( digestBuffer, providedBuffer )
|
|
455
|
+
){
|
|
456
|
+
|
|
457
|
+
throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
|
|
458
|
+
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The proof and the parse happen together on purpose. Nothing downstream of
|
|
462
|
+
// inbound.verify ever sees the raw bytes, which is what makes acting on
|
|
463
|
+
// unverified data impossible rather than merely discouraged.
|
|
464
|
+
return JSON.parse( body.toString() );
|
|
465
|
+
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
|
|
469
|
+
const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
|
|
470
|
+
|
|
471
|
+
// WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
|
|
472
|
+
// about someone else's product, so they belong beside the rest of the vendor
|
|
473
|
+
// rather than as string literals in a route — which is where they were, and is
|
|
474
|
+
// why a second inbound vendor meant a second route file.
|
|
475
|
+
//
|
|
476
|
+
// Pulled out to a const (rather than written inline under `inbound:` below) so
|
|
477
|
+
// the hooks further down can reference the SAME object the manifest publishes,
|
|
478
|
+
// instead of a second copy that could drift from it.
|
|
479
|
+
//
|
|
480
|
+
// `signature` describes an HMAC scheme the shared verifier can run: hash the
|
|
481
|
+
// raw body with the named secret and compare, constant-time, against the
|
|
482
|
+
// header. Vendors whose scheme is not that shape declare no signature block and
|
|
483
|
+
// implement inbound.verify themselves — a JWT-bodied vendor (Kinde) verifies
|
|
484
|
+
// against a JWKS and returns the decoded claims, and Twilio signs the
|
|
485
|
+
// registered URL rather than the body. That is why verify is a hook and not
|
|
486
|
+
// config.
|
|
487
|
+
const inbound = {
|
|
488
|
+
headers : {
|
|
489
|
+
event : 'x-shopify-topic',
|
|
490
|
+
id : 'x-shopify-webhook-id',
|
|
491
|
+
shop : 'x-shopify-shop-domain',
|
|
492
|
+
signature : 'x-shopify-hmac-sha256'
|
|
493
|
+
},
|
|
494
|
+
signature : {
|
|
495
|
+
algorithm : 'sha256',
|
|
496
|
+
encoding : 'base64',
|
|
497
|
+
secret : 'SHOPIFY_API_SECRET'
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Shopify's own GDPR deadline, not ours — these three are the only topics
|
|
502
|
+
// registered under `compliance_topics` in the app toml, and Shopify expects an
|
|
503
|
+
// answer even for a shop that has already uninstalled. Anything else on this
|
|
504
|
+
// action is refused here rather than buffered, the same way a bad signature is:
|
|
505
|
+
// it is a request that should never have arrived.
|
|
506
|
+
const COMPLIANCE_TOPICS = new Set([
|
|
507
|
+
'customers/data_request',
|
|
508
|
+
'customers/redact',
|
|
509
|
+
'shop/redact'
|
|
510
|
+
]);
|
|
511
|
+
|
|
385
512
|
// Shopify — installed from the App Store, never connected with keys.
|
|
386
513
|
var shopify = {
|
|
387
514
|
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
@@ -427,30 +554,38 @@ var shopify = {
|
|
|
427
554
|
}
|
|
428
555
|
],
|
|
429
556
|
group : 'ecommerce',
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
557
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
558
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
559
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
560
|
+
// action: /events buffers whatever arrives with the shop domain stamped on;
|
|
561
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
562
|
+
// is a legal deadline rather than a retry.
|
|
563
|
+
hooks : {
|
|
564
|
+
'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
565
|
+
'inbound.receive' : ({ action, event, headers, payload }) => {
|
|
566
|
+
|
|
567
|
+
if( action === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
568
|
+
|
|
569
|
+
throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
|
|
570
|
+
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
575
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
576
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
577
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
578
|
+
data : action === 'compliance'
|
|
579
|
+
? payload
|
|
580
|
+
: { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
|
|
581
|
+
provider : { id : headers[ inbound.headers.id ] || null }
|
|
582
|
+
};
|
|
583
|
+
|
|
446
584
|
},
|
|
447
|
-
|
|
448
|
-
algorithm : 'sha256',
|
|
449
|
-
encoding : 'base64',
|
|
450
|
-
secret : 'SHOPIFY_API_SECRET'
|
|
451
|
-
}
|
|
585
|
+
'inbound.verify' : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
452
586
|
},
|
|
453
587
|
icon: icon$1,
|
|
588
|
+
inbound,
|
|
454
589
|
label : 'shopify',
|
|
455
590
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
456
591
|
// exists and the app is fully configured. Requiring all four means it can
|
|
@@ -479,8 +614,9 @@ var shopify = {
|
|
|
479
614
|
'catalog.prices' : false,
|
|
480
615
|
'catalog.products' : true,
|
|
481
616
|
'catalog.promotions' : true,
|
|
482
|
-
'inbound.
|
|
483
|
-
'inbound.
|
|
617
|
+
'inbound.event' : true,
|
|
618
|
+
'inbound.process' : true,
|
|
619
|
+
'inbound.receive' : true,
|
|
484
620
|
'inbound.verify' : true,
|
|
485
621
|
'lifecycle.cleanup' : true,
|
|
486
622
|
'lifecycle.register' : true,
|
|
@@ -647,8 +783,9 @@ var webhook = {
|
|
|
647
783
|
'catalog.prices' : false,
|
|
648
784
|
'catalog.products' : false,
|
|
649
785
|
'catalog.promotions' : false,
|
|
650
|
-
'inbound.
|
|
651
|
-
'inbound.
|
|
786
|
+
'inbound.event' : false,
|
|
787
|
+
'inbound.process' : false,
|
|
788
|
+
'inbound.receive' : false,
|
|
652
789
|
'inbound.verify' : false,
|
|
653
790
|
'lifecycle.cleanup' : false,
|
|
654
791
|
'lifecycle.register' : false,
|
|
@@ -835,9 +972,9 @@ const build = ( manifest ) => {
|
|
|
835
972
|
// A vendor that receives from the outside must say where it puts the event
|
|
836
973
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
837
974
|
// is a request accepted and dropped rather than an error.
|
|
838
|
-
if( manifest.supports?.[ 'inbound.
|
|
975
|
+
if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
|
|
839
976
|
|
|
840
|
-
throw new Error( manifest.slug + ' supports inbound.
|
|
977
|
+
throw new Error( manifest.slug + ' supports inbound.event but declares no inbound.headers.event' );
|
|
841
978
|
|
|
842
979
|
}
|
|
843
980
|
|
|
@@ -1090,7 +1227,11 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
1090
1227
|
|
|
1091
1228
|
} catch ( error ) {
|
|
1092
1229
|
|
|
1093
|
-
|
|
1230
|
+
// A hook throws to REJECT, not just to report failure — inbound.verify
|
|
1231
|
+
// on a forged signature, inbound.receive on a topic outside the vendor's
|
|
1232
|
+
// declared set. The status rides along so a caller can answer 401 rather
|
|
1233
|
+
// than always folding a hook's own rejection into a 500.
|
|
1234
|
+
return { error : error?.message || 'failed', outcome : OUTCOMES.failed, status : error?.status || null };
|
|
1094
1235
|
|
|
1095
1236
|
}
|
|
1096
1237
|
|
|
@@ -1216,7 +1357,7 @@ const resolveConnection = ( item, data ) => {
|
|
|
1216
1357
|
|
|
1217
1358
|
return Object.fromEntries(
|
|
1218
1359
|
Object.entries( item )
|
|
1219
|
-
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1360
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1220
1361
|
.map( ( [ key, value ] ) => [
|
|
1221
1362
|
key,
|
|
1222
1363
|
( typeof value === 'function' ? value( data ) : value )
|
|
@@ -26,14 +26,49 @@ var HOOKS = Object.freeze({
|
|
|
26
26
|
"cleanup"
|
|
27
27
|
]),
|
|
28
28
|
// Receiving from the vendor.
|
|
29
|
+
//
|
|
30
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
31
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
32
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
33
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
34
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
35
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
36
|
+
// ever knowing.
|
|
37
|
+
//
|
|
38
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
39
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
40
|
+
// into duplicate deliveries.
|
|
29
41
|
inbound: Object.freeze([
|
|
30
|
-
// Prove the request came from the vendor
|
|
31
|
-
//
|
|
42
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
43
|
+
// carries. One hook rather than two because for some vendors they are
|
|
44
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
45
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
46
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
47
|
+
//
|
|
48
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
49
|
+
// this returned, which means nothing downstream can act on unverified
|
|
50
|
+
// data even by mistake.
|
|
51
|
+
//
|
|
52
|
+
// It is also where a request gets refused for any other reason — an event
|
|
53
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
54
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
32
55
|
"verify",
|
|
33
|
-
// Name the event, from wherever this vendor puts it.
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
"
|
|
56
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
57
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
58
|
+
// JWT-bodied vendor.
|
|
59
|
+
"event",
|
|
60
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
61
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
62
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
63
|
+
// carriers require) returns that too.
|
|
64
|
+
"receive",
|
|
65
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
66
|
+
//
|
|
67
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
68
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
69
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
70
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
71
|
+
"process"
|
|
37
72
|
]),
|
|
38
73
|
// Vendor data a campaign draws on. Named for what every store platform has,
|
|
39
74
|
// not for what Shopify calls it: Shopify says discounts, Stripe says coupons
|
|
@@ -259,8 +294,9 @@ var klaviyo_default2 = {
|
|
|
259
294
|
"catalog.prices": false,
|
|
260
295
|
"catalog.products": false,
|
|
261
296
|
"catalog.promotions": false,
|
|
262
|
-
"inbound.
|
|
263
|
-
"inbound.
|
|
297
|
+
"inbound.event": false,
|
|
298
|
+
"inbound.process": false,
|
|
299
|
+
"inbound.receive": false,
|
|
264
300
|
"inbound.verify": false,
|
|
265
301
|
"lifecycle.cleanup": false,
|
|
266
302
|
"lifecycle.register": false,
|
|
@@ -330,8 +366,9 @@ var mailchimp_default2 = {
|
|
|
330
366
|
"catalog.prices": false,
|
|
331
367
|
"catalog.products": false,
|
|
332
368
|
"catalog.promotions": false,
|
|
333
|
-
"inbound.
|
|
334
|
-
"inbound.
|
|
369
|
+
"inbound.event": false,
|
|
370
|
+
"inbound.process": false,
|
|
371
|
+
"inbound.receive": false,
|
|
335
372
|
"inbound.verify": false,
|
|
336
373
|
"lifecycle.cleanup": false,
|
|
337
374
|
"lifecycle.register": false,
|
|
@@ -365,7 +402,42 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
365
402
|
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
366
403
|
</svg>`;
|
|
367
404
|
|
|
405
|
+
// lib/connections/inbound.js
|
|
406
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
407
|
+
var verifySignature = ({ body, descriptor, headers }) => {
|
|
408
|
+
const provided = headers[descriptor.headers.signature];
|
|
409
|
+
if (!provided) {
|
|
410
|
+
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
411
|
+
}
|
|
412
|
+
const digest = createHmac(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
|
|
413
|
+
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
414
|
+
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
415
|
+
if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual(digestBuffer, providedBuffer)) {
|
|
416
|
+
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
417
|
+
}
|
|
418
|
+
return JSON.parse(body.toString());
|
|
419
|
+
};
|
|
420
|
+
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
421
|
+
|
|
368
422
|
// lib/connections/shopify.js
|
|
423
|
+
var inbound = {
|
|
424
|
+
headers: {
|
|
425
|
+
event: "x-shopify-topic",
|
|
426
|
+
id: "x-shopify-webhook-id",
|
|
427
|
+
shop: "x-shopify-shop-domain",
|
|
428
|
+
signature: "x-shopify-hmac-sha256"
|
|
429
|
+
},
|
|
430
|
+
signature: {
|
|
431
|
+
algorithm: "sha256",
|
|
432
|
+
encoding: "base64",
|
|
433
|
+
secret: "SHOPIFY_API_SECRET"
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
|
|
437
|
+
"customers/data_request",
|
|
438
|
+
"customers/redact",
|
|
439
|
+
"shop/redact"
|
|
440
|
+
]);
|
|
369
441
|
var shopify_default2 = {
|
|
370
442
|
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
371
443
|
// sees a consent screen we sent them to -- they start at the App Store, and
|
|
@@ -410,30 +482,31 @@ var shopify_default2 = {
|
|
|
410
482
|
}
|
|
411
483
|
],
|
|
412
484
|
group: "ecommerce",
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
485
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
486
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
487
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
488
|
+
// action: /events buffers whatever arrives with the shop domain stamped on;
|
|
489
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
490
|
+
// is a legal deadline rather than a retry.
|
|
491
|
+
hooks: {
|
|
492
|
+
"inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
493
|
+
"inbound.receive": ({ action, event, headers, payload }) => {
|
|
494
|
+
if (action === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
495
|
+
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
499
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
500
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
501
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
502
|
+
data: action === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
|
|
503
|
+
provider: { id: headers[inbound.headers.id] || null }
|
|
504
|
+
};
|
|
429
505
|
},
|
|
430
|
-
|
|
431
|
-
algorithm: "sha256",
|
|
432
|
-
encoding: "base64",
|
|
433
|
-
secret: "SHOPIFY_API_SECRET"
|
|
434
|
-
}
|
|
506
|
+
"inbound.verify": (args) => verifySignature({ ...args, descriptor: inbound })
|
|
435
507
|
},
|
|
436
508
|
icon: shopify_default,
|
|
509
|
+
inbound,
|
|
437
510
|
label: "shopify",
|
|
438
511
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
439
512
|
// exists and the app is fully configured. Requiring all four means it can
|
|
@@ -462,8 +535,9 @@ var shopify_default2 = {
|
|
|
462
535
|
"catalog.prices": false,
|
|
463
536
|
"catalog.products": true,
|
|
464
537
|
"catalog.promotions": true,
|
|
465
|
-
"inbound.
|
|
466
|
-
"inbound.
|
|
538
|
+
"inbound.event": true,
|
|
539
|
+
"inbound.process": true,
|
|
540
|
+
"inbound.receive": true,
|
|
467
541
|
"inbound.verify": true,
|
|
468
542
|
"lifecycle.cleanup": true,
|
|
469
543
|
"lifecycle.register": true,
|
|
@@ -623,8 +697,9 @@ var webhook_default = {
|
|
|
623
697
|
"catalog.prices": false,
|
|
624
698
|
"catalog.products": false,
|
|
625
699
|
"catalog.promotions": false,
|
|
626
|
-
"inbound.
|
|
627
|
-
"inbound.
|
|
700
|
+
"inbound.event": false,
|
|
701
|
+
"inbound.process": false,
|
|
702
|
+
"inbound.receive": false,
|
|
628
703
|
"inbound.verify": false,
|
|
629
704
|
"lifecycle.cleanup": false,
|
|
630
705
|
"lifecycle.register": false,
|
|
@@ -714,8 +789,8 @@ var build = (manifest) => {
|
|
|
714
789
|
}
|
|
715
790
|
}
|
|
716
791
|
}
|
|
717
|
-
if (((_d = manifest.supports) == null ? void 0 : _d["inbound.
|
|
718
|
-
throw new Error(manifest.slug + " supports inbound.
|
|
792
|
+
if (((_d = manifest.supports) == null ? void 0 : _d["inbound.event"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.event)) {
|
|
793
|
+
throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
|
|
719
794
|
}
|
|
720
795
|
if (((_g = manifest.supports) == null ? void 0 : _g["inbound.verify"]) && !((_i = (_h = manifest.inbound) == null ? void 0 : _h.headers) == null ? void 0 : _i.signature)) {
|
|
721
796
|
throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
|
|
@@ -830,7 +905,7 @@ var runHook = async (slug, name, args = {}) => {
|
|
|
830
905
|
try {
|
|
831
906
|
return { outcome: OUTCOMES.answered, result: await hook(args) };
|
|
832
907
|
} catch (error) {
|
|
833
|
-
return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed };
|
|
908
|
+
return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
|
|
834
909
|
}
|
|
835
910
|
};
|
|
836
911
|
var stepQueues = (env = {}) => Object.fromEntries(
|
|
@@ -896,7 +971,7 @@ var projectConnection = (record) => {
|
|
|
896
971
|
var resolveConnection = (item, data) => {
|
|
897
972
|
if (!item) return item;
|
|
898
973
|
return Object.fromEntries(
|
|
899
|
-
Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
|
|
974
|
+
Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
|
|
900
975
|
key,
|
|
901
976
|
typeof value === "function" ? value(data) : value
|
|
902
977
|
])
|
package/package.json
CHANGED