@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.
@@ -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. Signature schemes differ per
81
- // vendor, which is exactly why this is a hook and not one shared function.
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
- "topic",
85
- // Do the work the event implies.
86
- "handle"
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.handle": false,
313
- "inbound.topic": false,
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.handle": false,
384
- "inbound.topic": false,
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
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
464
- // about someone else's product, so they belong beside the rest of the vendor
465
- // rather than as string literals in a route which is where they were, and
466
- // is why a second inbound vendor meant a second route file.
467
- //
468
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
469
- // raw body with the named secret and compare, constant-time, against the
470
- // header. Vendors whose scheme is not that shape — Stripe signs a timestamped
471
- // payload declare no signature block and implement inbound.verify instead.
472
- // That is why verify is a hook and not config.
473
- inbound: {
474
- headers: {
475
- id: "x-shopify-webhook-id",
476
- shop: "x-shopify-shop-domain",
477
- signature: "x-shopify-hmac-sha256",
478
- topic: "x-shopify-topic"
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
- signature: {
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.handle": true,
516
- "inbound.topic": true,
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.handle": false,
677
- "inbound.topic": false,
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.topic"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.topic)) {
768
- throw new Error(manifest.slug + " supports inbound.topic but declares no inbound.headers.topic");
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. Signature schemes differ per
53
- // vendor, which is exactly why this is a hook and not one shared function.
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
- 'topic',
57
- // Do the work the event implies.
58
- 'handle'
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.handle' : false,
265
- 'inbound.topic' : false,
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.handle' : false,
346
- 'inbound.topic' : false,
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
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
431
- // about someone else's product, so they belong beside the rest of the vendor
432
- // rather than as string literals in a route which is where they were, and
433
- // is why a second inbound vendor meant a second route file.
434
- //
435
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
436
- // raw body with the named secret and compare, constant-time, against the
437
- // header. Vendors whose scheme is not that shape Stripe signs a timestamped
438
- // payload declare no signature block and implement inbound.verify instead.
439
- // That is why verify is a hook and not config.
440
- inbound : {
441
- headers : {
442
- id : 'x-shopify-webhook-id',
443
- shop : 'x-shopify-shop-domain',
444
- signature : 'x-shopify-hmac-sha256',
445
- topic : 'x-shopify-topic'
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
- signature : {
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.handle' : true,
483
- 'inbound.topic' : true,
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.handle' : false,
651
- 'inbound.topic' : false,
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.topic' ] && ! manifest.inbound?.headers?.topic ){
975
+ if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
839
976
 
840
- throw new Error( manifest.slug + ' supports inbound.topic but declares no inbound.headers.topic' );
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
- return { error : error?.message || 'failed', outcome : OUTCOMES.failed };
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. Signature schemes differ per
53
- // vendor, which is exactly why this is a hook and not one shared function.
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
- 'topic',
57
- // Do the work the event implies.
58
- 'handle'
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.handle' : false,
265
- 'inbound.topic' : false,
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.handle' : false,
346
- 'inbound.topic' : false,
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
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
431
- // about someone else's product, so they belong beside the rest of the vendor
432
- // rather than as string literals in a route which is where they were, and
433
- // is why a second inbound vendor meant a second route file.
434
- //
435
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
436
- // raw body with the named secret and compare, constant-time, against the
437
- // header. Vendors whose scheme is not that shape Stripe signs a timestamped
438
- // payload declare no signature block and implement inbound.verify instead.
439
- // That is why verify is a hook and not config.
440
- inbound : {
441
- headers : {
442
- id : 'x-shopify-webhook-id',
443
- shop : 'x-shopify-shop-domain',
444
- signature : 'x-shopify-hmac-sha256',
445
- topic : 'x-shopify-topic'
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
- signature : {
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.handle' : true,
483
- 'inbound.topic' : true,
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.handle' : false,
651
- 'inbound.topic' : false,
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.topic' ] && ! manifest.inbound?.headers?.topic ){
975
+ if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
839
976
 
840
- throw new Error( manifest.slug + ' supports inbound.topic but declares no inbound.headers.topic' );
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
- return { error : error?.message || 'failed', outcome : OUTCOMES.failed };
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. Signature schemes differ per
31
- // vendor, which is exactly why this is a hook and not one shared function.
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
- "topic",
35
- // Do the work the event implies.
36
- "handle"
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.handle": false,
263
- "inbound.topic": false,
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.handle": false,
334
- "inbound.topic": false,
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
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
414
- // about someone else's product, so they belong beside the rest of the vendor
415
- // rather than as string literals in a route which is where they were, and
416
- // is why a second inbound vendor meant a second route file.
417
- //
418
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
419
- // raw body with the named secret and compare, constant-time, against the
420
- // header. Vendors whose scheme is not that shape — Stripe signs a timestamped
421
- // payload declare no signature block and implement inbound.verify instead.
422
- // That is why verify is a hook and not config.
423
- inbound: {
424
- headers: {
425
- id: "x-shopify-webhook-id",
426
- shop: "x-shopify-shop-domain",
427
- signature: "x-shopify-hmac-sha256",
428
- topic: "x-shopify-topic"
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
- signature: {
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.handle": true,
466
- "inbound.topic": true,
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.handle": false,
627
- "inbound.topic": false,
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.topic"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.topic)) {
718
- throw new Error(manifest.slug + " supports inbound.topic but declares no inbound.headers.topic");
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
@@ -200,5 +200,5 @@
200
200
  "test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
201
201
  },
202
202
  "types": "dist/index.d.ts",
203
- "version": "0.0.107"
203
+ "version": "0.0.108"
204
204
  }