@drawbridge/drawbridge-utils 0.0.153 → 0.0.155

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.
@@ -275,6 +275,7 @@ var HOOK_PROPS = Object.freeze([
275
275
  "scope",
276
276
  "search",
277
277
  "secret",
278
+ "segments",
278
279
  "settings",
279
280
  "sort",
280
281
  "step",
@@ -2668,10 +2669,15 @@ var klaviyo_default2 = {
2668
2669
  excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
2669
2670
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2670
2671
  // rendered — four useful instructions no component displayed.
2672
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2673
+ // list chosen sits at Pending, and this told the merchant it would show
2674
+ // Active. The one required action was the one action the guide omitted, so
2675
+ // anybody following it landed on a connection that looked broken.
2671
2676
  guide: [
2672
2677
  "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
2673
2678
  "Sign in to Klaviyo if you are not already, and choose the account to connect.",
2674
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
2679
+ "Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.",
2680
+ "The connection shows Pending until you pick a list, then Active.",
2675
2681
  "You can revoke access at any time from Klaviyo, under Integrations."
2676
2682
  ]
2677
2683
  },
@@ -2793,14 +2799,15 @@ var klaviyo_default2 = {
2793
2799
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2794
2800
  // different thing from deleting the profile.
2795
2801
  remove: false,
2796
- sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
2802
+ sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
2797
2803
  var _a, _b, _c;
2798
2804
  const list = settings == null ? void 0 : settings.list;
2799
2805
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
2800
2806
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
2801
2807
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2802
- const totals = (contact == null ? void 0 : contact.totals) || {};
2803
- const profile = await api2("/profiles/", {
2808
+ const person = (context == null ? void 0 : context.contact) || null;
2809
+ const totals = (person == null ? void 0 : person.totals) || {};
2810
+ const profile = await api2("/profile-import", {
2804
2811
  fetcher,
2805
2812
  method: "POST",
2806
2813
  payload: {
@@ -2809,16 +2816,36 @@ var klaviyo_default2 = {
2809
2816
  email,
2810
2817
  ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
2811
2818
  properties: {
2812
- drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
2813
- drawbridge_draws: totals.draws || 0,
2814
- drawbridge_entries: totals.entries || 0,
2815
- drawbridge_orders: totals.orders || 0,
2816
- // Campaign-attributed, NOT lifetime. A merchant running
2817
- // Shopify already has lifetime revenue in Klaviyo through
2818
- // Klaviyo's own integration; what only we can say is how
2819
- // much a campaign drove. Named so the two cannot be
2820
- // mistaken for one another in a segment builder.
2821
- drawbridge_revenue: totals.gross || 0
2819
+ ...person && {
2820
+ drawbridge_campaigns: (person.campaigns || []).length,
2821
+ drawbridge_draws: totals.draws || 0,
2822
+ drawbridge_entries: totals.entries || 0,
2823
+ drawbridge_orders: totals.orders || 0,
2824
+ // Campaign-attributed, NOT lifetime. A merchant running
2825
+ // Shopify already has lifetime revenue in Klaviyo through
2826
+ // Klaviyo's own integration; what only we can say is how
2827
+ // much a campaign drove. Named so the two cannot be
2828
+ // mistaken for one another in a segment builder.
2829
+ drawbridge_revenue: totals.gross || 0
2830
+ },
2831
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
2832
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
2833
+ // writable membership — its segments are computed from rules,
2834
+ // so a profile cannot be put in one — and this is the nearest
2835
+ // honest equivalent.
2836
+ //
2837
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
2838
+ // segments are dynamic and nothing dispatches a leave event —
2839
+ // only segment.contact.add exists — so a property that was
2840
+ // only ever appended to would outlive the membership that
2841
+ // earned it. Rewriting the whole array each run means every
2842
+ // sync corrects whatever the last one got wrong, with no
2843
+ // removal path to build.
2844
+ //
2845
+ // `segments` is null when the run carried no contact document,
2846
+ // meaning nobody looked — different from [], which means they
2847
+ // are in none. Null omits the key and merge leaves it alone.
2848
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
2822
2849
  }
2823
2850
  },
2824
2851
  type: "profile"
@@ -3110,7 +3137,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3110
3137
  { status: response.status }
3111
3138
  );
3112
3139
  }
3113
- return response.json();
3140
+ return response.status === 204 ? null : response.json();
3114
3141
  };
3115
3142
  var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3116
3143
  var mailchimp_default2 = {
@@ -3244,7 +3271,7 @@ var mailchimp_default2 = {
3244
3271
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3245
3272
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3246
3273
  // Marketing API reference for the list-members resource.
3247
- sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
3274
+ sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3248
3275
  var _a, _b;
3249
3276
  const audience = settings == null ? void 0 : settings.audience;
3250
3277
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
@@ -3270,6 +3297,36 @@ var mailchimp_default2 = {
3270
3297
  },
3271
3298
  token
3272
3299
  });
3300
+ if (segments && read) {
3301
+ const owned = await read.aggregate({
3302
+ collection: "segment",
3303
+ pipeline: [
3304
+ {
3305
+ $match: {
3306
+ organization: connection2 == null ? void 0 : connection2.organization,
3307
+ system: { $ne: true }
3308
+ }
3309
+ },
3310
+ {
3311
+ $project: { _id: 0, title: 1 }
3312
+ }
3313
+ ]
3314
+ });
3315
+ const joined = new Set(segments.map((entry) => entry.title));
3316
+ const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3317
+ name: "Drawbridge: " + title,
3318
+ status: joined.has(title) ? "active" : "inactive"
3319
+ }));
3320
+ if (tags.length > 0) {
3321
+ await api3("/lists/" + audience + "/members/" + hash + "/tags", {
3322
+ dc: settings == null ? void 0 : settings.dc,
3323
+ fetcher,
3324
+ method: "POST",
3325
+ payload: { tags },
3326
+ token
3327
+ });
3328
+ }
3329
+ }
3273
3330
  return {
3274
3331
  // Merged into `context` for later steps in this run.
3275
3332
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
@@ -4130,7 +4187,9 @@ var shopify_default2 = {
4130
4187
  enqueues: [{
4131
4188
  data: { product: productId, providerId, shop: connection2.shop },
4132
4189
  name: "workflow",
4133
- options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
4190
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4191
+ // colon in it, and every gid carries two.
4192
+ options: { jobId: "product.workflow.shopify." + context.id + "." + Date.now() },
4134
4193
  queue: "product.shopify"
4135
4194
  }],
4136
4195
  message: "Product sync queued from Shopify webhook.",
@@ -4936,9 +4995,14 @@ var webhook_default = {
4936
4995
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
4937
4996
  ],
4938
4997
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
4998
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
4999
+ // following this had nowhere to put the endpoint they came to configure —
5000
+ // the destination lives on the step (see steps.webhook.send.settings), and
5001
+ // the guide never said so.
4939
5002
  guide: [
4940
5003
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4941
5004
  "Copy the secret into your own endpoint.",
5005
+ "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4942
5006
  "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
4943
5007
  ]
4944
5008
  },
@@ -334,6 +334,10 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
334
334
  // payload the verified inbound body
335
335
  // scope the granted scope string being judged
336
336
  // secret the credential VALUE the caller resolved for verification
337
+ // segments the non-system segments this contact is in, as { id, title },
338
+ // resolved by the shell — null when the run carried no contact
339
+ // document, which means nobody looked, where [] means they are
340
+ // in none. A hook handed null leaves the vendor's copy alone
337
341
  // settings the connection's decrypted settings
338
342
  // step the step document
339
343
  // suppressed the opt-out floor's answer for this lead, computed by the shell
@@ -383,7 +387,7 @@ const HOOK_PROPS = Object.freeze([
383
387
  'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
384
388
  'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
385
389
  'manifest', 'payload',
386
- 'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
390
+ 'scope', 'search', 'secret', 'segments', 'settings', 'sort', 'step', 'suppressed',
387
391
  'token', 'tokens', 'workflow'
388
392
  ]);
389
393
 
@@ -2834,10 +2838,15 @@ var klaviyo = {
2834
2838
 
2835
2839
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2836
2840
  // rendered — four useful instructions no component displayed.
2841
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2842
+ // list chosen sits at Pending, and this told the merchant it would show
2843
+ // Active. The one required action was the one action the guide omitted, so
2844
+ // anybody following it landed on a connection that looked broken.
2837
2845
  guide : [
2838
2846
  'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
2839
2847
  'Sign in to Klaviyo if you are not already, and choose the account to connect.',
2840
- 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
2848
+ 'Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.',
2849
+ 'The connection shows Pending until you pick a list, then Active.',
2841
2850
  'You can revoke access at any time from Klaviyo, under Integrations.'
2842
2851
  ]
2843
2852
 
@@ -2987,7 +2996,7 @@ var klaviyo = {
2987
2996
  // different thing from deleting the profile.
2988
2997
  remove : false,
2989
2998
 
2990
- sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
2999
+ sync : async ( { context, lead, segments, settings, suppressed, token }, { fetcher } = {} ) => {
2991
3000
 
2992
3001
  const list = settings?.list;
2993
3002
 
@@ -3005,9 +3014,32 @@ var klaviyo = {
3005
3014
  // contact knows money: order attribution is person-level and merges
3006
3015
  // across every address a human used, so revenue and orders cannot
3007
3016
  // exist on a single-address record.
3008
- const totals = contact?.totals || {};
3009
-
3010
- const profile = await api$1( '/profiles/', {
3017
+ //
3018
+ // READ OFF `context`, NOT the shell's `contact` prop. That prop is the
3019
+ // ADDRESS a string so `contact?.totals` was undefined on every run
3020
+ // this hook has ever made, and all five properties below shipped as 0
3021
+ // no matter what the person had entered, drawn or spent. Only a
3022
+ // segment.contact.add run carries the document (drawbridge-sync
3023
+ // lib/segment.js); lead.insert names a brand-new entrant who has no
3024
+ // history to send anyway.
3025
+ const person = context?.contact || null;
3026
+ const totals = person?.totals || {};
3027
+
3028
+ // UPSERT, NOT CREATE. /profiles/ is the plain create and answers a
3029
+ // conflict for a profile Klaviyo already holds — which is most of them,
3030
+ // because the whole point of the segment trigger is syncing people who
3031
+ // have been around long enough to earn a ranking. profile-import is
3032
+ // Klaviyo's documented create-or-update: 201 when it made one, 200 when
3033
+ // it updated one, and no conflict either way
3034
+ // (developers.klaviyo.com/en/reference/create_or_update_profile,
3035
+ // fetched 2026-09-09).
3036
+ //
3037
+ // IT MERGES, and that is why the blocks below are conditional rather
3038
+ // than defaulted. "Not including a field in your request will leave it
3039
+ // unchanged" — so omitting the totals on a lead.insert preserves
3040
+ // whatever the last segment run published, where sending zeros would
3041
+ // erase it. A property we cannot compute is one we must not write.
3042
+ const profile = await api$1( '/profile-import', {
3011
3043
  fetcher,
3012
3044
  method : 'POST',
3013
3045
  payload : {
@@ -3016,16 +3048,36 @@ var klaviyo = {
3016
3048
  email,
3017
3049
  ...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
3018
3050
  properties : {
3019
- drawbridge_campaigns : ( contact?.campaigns || [] ).length,
3020
- drawbridge_draws : totals.draws || 0,
3021
- drawbridge_entries : totals.entries || 0,
3022
- drawbridge_orders : totals.orders || 0,
3023
- // Campaign-attributed, NOT lifetime. A merchant running
3024
- // Shopify already has lifetime revenue in Klaviyo through
3025
- // Klaviyo's own integration; what only we can say is how
3026
- // much a campaign drove. Named so the two cannot be
3027
- // mistaken for one another in a segment builder.
3028
- drawbridge_revenue : totals.gross || 0
3051
+ ...( person && {
3052
+ drawbridge_campaigns : ( person.campaigns || [] ).length,
3053
+ drawbridge_draws : totals.draws || 0,
3054
+ drawbridge_entries : totals.entries || 0,
3055
+ drawbridge_orders : totals.orders || 0,
3056
+ // Campaign-attributed, NOT lifetime. A merchant running
3057
+ // Shopify already has lifetime revenue in Klaviyo through
3058
+ // Klaviyo's own integration; what only we can say is how
3059
+ // much a campaign drove. Named so the two cannot be
3060
+ // mistaken for one another in a segment builder.
3061
+ drawbridge_revenue : totals.gross || 0
3062
+ }),
3063
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3064
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
3065
+ // writable membership — its segments are computed from rules,
3066
+ // so a profile cannot be put in one — and this is the nearest
3067
+ // honest equivalent.
3068
+ //
3069
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
3070
+ // segments are dynamic and nothing dispatches a leave event —
3071
+ // only segment.contact.add exists — so a property that was
3072
+ // only ever appended to would outlive the membership that
3073
+ // earned it. Rewriting the whole array each run means every
3074
+ // sync corrects whatever the last one got wrong, with no
3075
+ // removal path to build.
3076
+ //
3077
+ // `segments` is null when the run carried no contact document,
3078
+ // meaning nobody looked — different from [], which means they
3079
+ // are in none. Null omits the key and merge leaves it alone.
3080
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3029
3081
  }
3030
3082
  },
3031
3083
  type : 'profile'
@@ -3427,7 +3479,10 @@ const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token
3427
3479
 
3428
3480
  }
3429
3481
 
3430
- return response.json();
3482
+ // 204 ON A TAG WRITE — nothing to parse, and asking an empty body for json
3483
+ // throws, which would report a change Mailchimp accepted as a failed step.
3484
+ // Klaviyo's file carries the same guard for the same reason.
3485
+ return response.status === 204 ? null : response.json();
3431
3486
 
3432
3487
  };
3433
3488
 
@@ -3591,7 +3646,7 @@ var mailchimp = {
3591
3646
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3592
3647
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3593
3648
  // Marketing API reference for the list-members resource.
3594
- sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
3649
+ sync : async ( { connection, lead, segments, settings, suppressed, token }, { fetcher, read } = {} ) => {
3595
3650
 
3596
3651
  const audience = settings?.audience;
3597
3652
 
@@ -3648,6 +3703,75 @@ var mailchimp = {
3648
3703
  token
3649
3704
  });
3650
3705
 
3706
+ // THE DRAWBRIDGE SEGMENTS THIS PERSON IS IN, written as Mailchimp tags.
3707
+ //
3708
+ // TAGS, NOT MERGE FIELDS. A merge field has to exist on the audience
3709
+ // before anything can be written to it, and an unregistered one is
3710
+ // refused along with the whole member request — so carrying data that
3711
+ // way means registering fields first, which is lifecycle.register's job
3712
+ // and is not built. A tag needs no setup: "If a tag that does not exist
3713
+ // is passed in and set as 'active', a new tag will be created"
3714
+ // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
3715
+ // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
3716
+ // to explain in the guide.
3717
+ //
3718
+ // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
3719
+ // over time. Drawbridge segments are dynamic, and nothing dispatches a
3720
+ // leave event — only segment.contact.add exists — so a tag that was
3721
+ // only ever added would outlive the membership behind it and the
3722
+ // merchant would target a condition that had stopped being true.
3723
+ // Sending the whole list every time, each tag active or inactive
3724
+ // against current membership, means each sync corrects the last.
3725
+ //
3726
+ // WRITTEN FOR A SUPPRESSED PERSON TOO. A tag is a label, not a
3727
+ // permission: the `status : 'unsubscribed'` above is what stops mail
3728
+ // reaching them, and letting their labels rot would leave the merchant
3729
+ // a wrong answer about who belongs to what.
3730
+ //
3731
+ // PREFIXED, so a Drawbridge tag cannot collide with one the merchant
3732
+ // keeps by hand, and so the inactive pass can only clear tags this
3733
+ // integration wrote.
3734
+ if( segments && read ){
3735
+
3736
+ const owned = await read.aggregate({
3737
+ collection : 'segment',
3738
+ pipeline : [
3739
+ {
3740
+ $match : {
3741
+ organization : connection?.organization,
3742
+ system : { $ne : true }
3743
+ }
3744
+ },
3745
+ {
3746
+ $project : { _id : 0, title : 1 }
3747
+ }
3748
+ ]
3749
+ });
3750
+
3751
+ const joined = new Set( segments.map( ( entry ) => entry.title ) );
3752
+
3753
+ const tags = ( owned || [] )
3754
+ .map( ( entry ) => entry.title )
3755
+ .filter( Boolean )
3756
+ .map( ( title ) => ({
3757
+ name : 'Drawbridge: ' + title,
3758
+ status : joined.has( title ) ? 'active' : 'inactive'
3759
+ }) );
3760
+
3761
+ if( tags.length > 0 ){
3762
+
3763
+ await api( '/lists/' + audience + '/members/' + hash + '/tags', {
3764
+ dc : settings?.dc,
3765
+ fetcher,
3766
+ method : 'POST',
3767
+ payload : { tags },
3768
+ token
3769
+ });
3770
+
3771
+ }
3772
+
3773
+ }
3774
+
3651
3775
  return {
3652
3776
  // Merged into `context` for later steps in this run.
3653
3777
  context : { mailchimpMemberId : member?.id || hash },
@@ -4828,7 +4952,9 @@ var shopify = {
4828
4952
  enqueues : [ {
4829
4953
  data : { product : productId, providerId, shop : connection.shop },
4830
4954
  name : 'workflow',
4831
- options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
4955
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4956
+ // colon in it, and every gid carries two.
4957
+ options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
4832
4958
  queue : 'product.shopify'
4833
4959
  } ],
4834
4960
  message : 'Product sync queued from Shopify webhook.',
@@ -5763,9 +5889,14 @@ var webhook = {
5763
5889
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
5764
5890
  ],
5765
5891
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
5892
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
5893
+ // following this had nowhere to put the endpoint they came to configure —
5894
+ // the destination lives on the step (see steps.webhook.send.settings), and
5895
+ // the guide never said so.
5766
5896
  guide : [
5767
5897
  'Press Connect. Drawbridge generates a signing secret and shows it here.',
5768
5898
  'Copy the secret into your own endpoint.',
5899
+ 'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
5769
5900
  'On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
5770
5901
  ]
5771
5902
  },
@@ -334,6 +334,10 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
334
334
  // payload the verified inbound body
335
335
  // scope the granted scope string being judged
336
336
  // secret the credential VALUE the caller resolved for verification
337
+ // segments the non-system segments this contact is in, as { id, title },
338
+ // resolved by the shell — null when the run carried no contact
339
+ // document, which means nobody looked, where [] means they are
340
+ // in none. A hook handed null leaves the vendor's copy alone
337
341
  // settings the connection's decrypted settings
338
342
  // step the step document
339
343
  // suppressed the opt-out floor's answer for this lead, computed by the shell
@@ -383,7 +387,7 @@ const HOOK_PROPS = Object.freeze([
383
387
  'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
384
388
  'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
385
389
  'manifest', 'payload',
386
- 'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
390
+ 'scope', 'search', 'secret', 'segments', 'settings', 'sort', 'step', 'suppressed',
387
391
  'token', 'tokens', 'workflow'
388
392
  ]);
389
393
 
@@ -2834,10 +2838,15 @@ var klaviyo = {
2834
2838
 
2835
2839
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2836
2840
  // rendered — four useful instructions no component displayed.
2841
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2842
+ // list chosen sits at Pending, and this told the merchant it would show
2843
+ // Active. The one required action was the one action the guide omitted, so
2844
+ // anybody following it landed on a connection that looked broken.
2837
2845
  guide : [
2838
2846
  'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
2839
2847
  'Sign in to Klaviyo if you are not already, and choose the account to connect.',
2840
- 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
2848
+ 'Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.',
2849
+ 'The connection shows Pending until you pick a list, then Active.',
2841
2850
  'You can revoke access at any time from Klaviyo, under Integrations.'
2842
2851
  ]
2843
2852
 
@@ -2987,7 +2996,7 @@ var klaviyo = {
2987
2996
  // different thing from deleting the profile.
2988
2997
  remove : false,
2989
2998
 
2990
- sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
2999
+ sync : async ( { context, lead, segments, settings, suppressed, token }, { fetcher } = {} ) => {
2991
3000
 
2992
3001
  const list = settings?.list;
2993
3002
 
@@ -3005,9 +3014,32 @@ var klaviyo = {
3005
3014
  // contact knows money: order attribution is person-level and merges
3006
3015
  // across every address a human used, so revenue and orders cannot
3007
3016
  // exist on a single-address record.
3008
- const totals = contact?.totals || {};
3009
-
3010
- const profile = await api$1( '/profiles/', {
3017
+ //
3018
+ // READ OFF `context`, NOT the shell's `contact` prop. That prop is the
3019
+ // ADDRESS a string so `contact?.totals` was undefined on every run
3020
+ // this hook has ever made, and all five properties below shipped as 0
3021
+ // no matter what the person had entered, drawn or spent. Only a
3022
+ // segment.contact.add run carries the document (drawbridge-sync
3023
+ // lib/segment.js); lead.insert names a brand-new entrant who has no
3024
+ // history to send anyway.
3025
+ const person = context?.contact || null;
3026
+ const totals = person?.totals || {};
3027
+
3028
+ // UPSERT, NOT CREATE. /profiles/ is the plain create and answers a
3029
+ // conflict for a profile Klaviyo already holds — which is most of them,
3030
+ // because the whole point of the segment trigger is syncing people who
3031
+ // have been around long enough to earn a ranking. profile-import is
3032
+ // Klaviyo's documented create-or-update: 201 when it made one, 200 when
3033
+ // it updated one, and no conflict either way
3034
+ // (developers.klaviyo.com/en/reference/create_or_update_profile,
3035
+ // fetched 2026-09-09).
3036
+ //
3037
+ // IT MERGES, and that is why the blocks below are conditional rather
3038
+ // than defaulted. "Not including a field in your request will leave it
3039
+ // unchanged" — so omitting the totals on a lead.insert preserves
3040
+ // whatever the last segment run published, where sending zeros would
3041
+ // erase it. A property we cannot compute is one we must not write.
3042
+ const profile = await api$1( '/profile-import', {
3011
3043
  fetcher,
3012
3044
  method : 'POST',
3013
3045
  payload : {
@@ -3016,16 +3048,36 @@ var klaviyo = {
3016
3048
  email,
3017
3049
  ...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
3018
3050
  properties : {
3019
- drawbridge_campaigns : ( contact?.campaigns || [] ).length,
3020
- drawbridge_draws : totals.draws || 0,
3021
- drawbridge_entries : totals.entries || 0,
3022
- drawbridge_orders : totals.orders || 0,
3023
- // Campaign-attributed, NOT lifetime. A merchant running
3024
- // Shopify already has lifetime revenue in Klaviyo through
3025
- // Klaviyo's own integration; what only we can say is how
3026
- // much a campaign drove. Named so the two cannot be
3027
- // mistaken for one another in a segment builder.
3028
- drawbridge_revenue : totals.gross || 0
3051
+ ...( person && {
3052
+ drawbridge_campaigns : ( person.campaigns || [] ).length,
3053
+ drawbridge_draws : totals.draws || 0,
3054
+ drawbridge_entries : totals.entries || 0,
3055
+ drawbridge_orders : totals.orders || 0,
3056
+ // Campaign-attributed, NOT lifetime. A merchant running
3057
+ // Shopify already has lifetime revenue in Klaviyo through
3058
+ // Klaviyo's own integration; what only we can say is how
3059
+ // much a campaign drove. Named so the two cannot be
3060
+ // mistaken for one another in a segment builder.
3061
+ drawbridge_revenue : totals.gross || 0
3062
+ }),
3063
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3064
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
3065
+ // writable membership — its segments are computed from rules,
3066
+ // so a profile cannot be put in one — and this is the nearest
3067
+ // honest equivalent.
3068
+ //
3069
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
3070
+ // segments are dynamic and nothing dispatches a leave event —
3071
+ // only segment.contact.add exists — so a property that was
3072
+ // only ever appended to would outlive the membership that
3073
+ // earned it. Rewriting the whole array each run means every
3074
+ // sync corrects whatever the last one got wrong, with no
3075
+ // removal path to build.
3076
+ //
3077
+ // `segments` is null when the run carried no contact document,
3078
+ // meaning nobody looked — different from [], which means they
3079
+ // are in none. Null omits the key and merge leaves it alone.
3080
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3029
3081
  }
3030
3082
  },
3031
3083
  type : 'profile'
@@ -3427,7 +3479,10 @@ const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token
3427
3479
 
3428
3480
  }
3429
3481
 
3430
- return response.json();
3482
+ // 204 ON A TAG WRITE — nothing to parse, and asking an empty body for json
3483
+ // throws, which would report a change Mailchimp accepted as a failed step.
3484
+ // Klaviyo's file carries the same guard for the same reason.
3485
+ return response.status === 204 ? null : response.json();
3431
3486
 
3432
3487
  };
3433
3488
 
@@ -3591,7 +3646,7 @@ var mailchimp = {
3591
3646
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3592
3647
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3593
3648
  // Marketing API reference for the list-members resource.
3594
- sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
3649
+ sync : async ( { connection, lead, segments, settings, suppressed, token }, { fetcher, read } = {} ) => {
3595
3650
 
3596
3651
  const audience = settings?.audience;
3597
3652
 
@@ -3648,6 +3703,75 @@ var mailchimp = {
3648
3703
  token
3649
3704
  });
3650
3705
 
3706
+ // THE DRAWBRIDGE SEGMENTS THIS PERSON IS IN, written as Mailchimp tags.
3707
+ //
3708
+ // TAGS, NOT MERGE FIELDS. A merge field has to exist on the audience
3709
+ // before anything can be written to it, and an unregistered one is
3710
+ // refused along with the whole member request — so carrying data that
3711
+ // way means registering fields first, which is lifecycle.register's job
3712
+ // and is not built. A tag needs no setup: "If a tag that does not exist
3713
+ // is passed in and set as 'active', a new tag will be created"
3714
+ // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
3715
+ // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
3716
+ // to explain in the guide.
3717
+ //
3718
+ // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
3719
+ // over time. Drawbridge segments are dynamic, and nothing dispatches a
3720
+ // leave event — only segment.contact.add exists — so a tag that was
3721
+ // only ever added would outlive the membership behind it and the
3722
+ // merchant would target a condition that had stopped being true.
3723
+ // Sending the whole list every time, each tag active or inactive
3724
+ // against current membership, means each sync corrects the last.
3725
+ //
3726
+ // WRITTEN FOR A SUPPRESSED PERSON TOO. A tag is a label, not a
3727
+ // permission: the `status : 'unsubscribed'` above is what stops mail
3728
+ // reaching them, and letting their labels rot would leave the merchant
3729
+ // a wrong answer about who belongs to what.
3730
+ //
3731
+ // PREFIXED, so a Drawbridge tag cannot collide with one the merchant
3732
+ // keeps by hand, and so the inactive pass can only clear tags this
3733
+ // integration wrote.
3734
+ if( segments && read ){
3735
+
3736
+ const owned = await read.aggregate({
3737
+ collection : 'segment',
3738
+ pipeline : [
3739
+ {
3740
+ $match : {
3741
+ organization : connection?.organization,
3742
+ system : { $ne : true }
3743
+ }
3744
+ },
3745
+ {
3746
+ $project : { _id : 0, title : 1 }
3747
+ }
3748
+ ]
3749
+ });
3750
+
3751
+ const joined = new Set( segments.map( ( entry ) => entry.title ) );
3752
+
3753
+ const tags = ( owned || [] )
3754
+ .map( ( entry ) => entry.title )
3755
+ .filter( Boolean )
3756
+ .map( ( title ) => ({
3757
+ name : 'Drawbridge: ' + title,
3758
+ status : joined.has( title ) ? 'active' : 'inactive'
3759
+ }) );
3760
+
3761
+ if( tags.length > 0 ){
3762
+
3763
+ await api( '/lists/' + audience + '/members/' + hash + '/tags', {
3764
+ dc : settings?.dc,
3765
+ fetcher,
3766
+ method : 'POST',
3767
+ payload : { tags },
3768
+ token
3769
+ });
3770
+
3771
+ }
3772
+
3773
+ }
3774
+
3651
3775
  return {
3652
3776
  // Merged into `context` for later steps in this run.
3653
3777
  context : { mailchimpMemberId : member?.id || hash },
@@ -4828,7 +4952,9 @@ var shopify = {
4828
4952
  enqueues : [ {
4829
4953
  data : { product : productId, providerId, shop : connection.shop },
4830
4954
  name : 'workflow',
4831
- options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
4955
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4956
+ // colon in it, and every gid carries two.
4957
+ options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
4832
4958
  queue : 'product.shopify'
4833
4959
  } ],
4834
4960
  message : 'Product sync queued from Shopify webhook.',
@@ -5763,9 +5889,14 @@ var webhook = {
5763
5889
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
5764
5890
  ],
5765
5891
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
5892
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
5893
+ // following this had nowhere to put the endpoint they came to configure —
5894
+ // the destination lives on the step (see steps.webhook.send.settings), and
5895
+ // the guide never said so.
5766
5896
  guide : [
5767
5897
  'Press Connect. Drawbridge generates a signing secret and shows it here.',
5768
5898
  'Copy the secret into your own endpoint.',
5899
+ 'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
5769
5900
  'On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
5770
5901
  ]
5771
5902
  },
@@ -199,6 +199,7 @@ var HOOK_PROPS = Object.freeze([
199
199
  "scope",
200
200
  "search",
201
201
  "secret",
202
+ "segments",
202
203
  "settings",
203
204
  "sort",
204
205
  "step",
@@ -2592,10 +2593,15 @@ var klaviyo_default2 = {
2592
2593
  excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
2593
2594
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2594
2595
  // rendered — four useful instructions no component displayed.
2596
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2597
+ // list chosen sits at Pending, and this told the merchant it would show
2598
+ // Active. The one required action was the one action the guide omitted, so
2599
+ // anybody following it landed on a connection that looked broken.
2595
2600
  guide: [
2596
2601
  "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
2597
2602
  "Sign in to Klaviyo if you are not already, and choose the account to connect.",
2598
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
2603
+ "Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.",
2604
+ "The connection shows Pending until you pick a list, then Active.",
2599
2605
  "You can revoke access at any time from Klaviyo, under Integrations."
2600
2606
  ]
2601
2607
  },
@@ -2717,14 +2723,15 @@ var klaviyo_default2 = {
2717
2723
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2718
2724
  // different thing from deleting the profile.
2719
2725
  remove: false,
2720
- sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
2726
+ sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
2721
2727
  var _a, _b, _c;
2722
2728
  const list = settings == null ? void 0 : settings.list;
2723
2729
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
2724
2730
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
2725
2731
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2726
- const totals = (contact == null ? void 0 : contact.totals) || {};
2727
- const profile = await api2("/profiles/", {
2732
+ const person = (context == null ? void 0 : context.contact) || null;
2733
+ const totals = (person == null ? void 0 : person.totals) || {};
2734
+ const profile = await api2("/profile-import", {
2728
2735
  fetcher,
2729
2736
  method: "POST",
2730
2737
  payload: {
@@ -2733,16 +2740,36 @@ var klaviyo_default2 = {
2733
2740
  email,
2734
2741
  ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
2735
2742
  properties: {
2736
- drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
2737
- drawbridge_draws: totals.draws || 0,
2738
- drawbridge_entries: totals.entries || 0,
2739
- drawbridge_orders: totals.orders || 0,
2740
- // Campaign-attributed, NOT lifetime. A merchant running
2741
- // Shopify already has lifetime revenue in Klaviyo through
2742
- // Klaviyo's own integration; what only we can say is how
2743
- // much a campaign drove. Named so the two cannot be
2744
- // mistaken for one another in a segment builder.
2745
- drawbridge_revenue: totals.gross || 0
2743
+ ...person && {
2744
+ drawbridge_campaigns: (person.campaigns || []).length,
2745
+ drawbridge_draws: totals.draws || 0,
2746
+ drawbridge_entries: totals.entries || 0,
2747
+ drawbridge_orders: totals.orders || 0,
2748
+ // Campaign-attributed, NOT lifetime. A merchant running
2749
+ // Shopify already has lifetime revenue in Klaviyo through
2750
+ // Klaviyo's own integration; what only we can say is how
2751
+ // much a campaign drove. Named so the two cannot be
2752
+ // mistaken for one another in a segment builder.
2753
+ drawbridge_revenue: totals.gross || 0
2754
+ },
2755
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
2756
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
2757
+ // writable membership — its segments are computed from rules,
2758
+ // so a profile cannot be put in one — and this is the nearest
2759
+ // honest equivalent.
2760
+ //
2761
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
2762
+ // segments are dynamic and nothing dispatches a leave event —
2763
+ // only segment.contact.add exists — so a property that was
2764
+ // only ever appended to would outlive the membership that
2765
+ // earned it. Rewriting the whole array each run means every
2766
+ // sync corrects whatever the last one got wrong, with no
2767
+ // removal path to build.
2768
+ //
2769
+ // `segments` is null when the run carried no contact document,
2770
+ // meaning nobody looked — different from [], which means they
2771
+ // are in none. Null omits the key and merge leaves it alone.
2772
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
2746
2773
  }
2747
2774
  },
2748
2775
  type: "profile"
@@ -3034,7 +3061,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3034
3061
  { status: response.status }
3035
3062
  );
3036
3063
  }
3037
- return response.json();
3064
+ return response.status === 204 ? null : response.json();
3038
3065
  };
3039
3066
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3040
3067
  var mailchimp_default2 = {
@@ -3168,7 +3195,7 @@ var mailchimp_default2 = {
3168
3195
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3169
3196
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3170
3197
  // Marketing API reference for the list-members resource.
3171
- sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
3198
+ sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3172
3199
  var _a, _b;
3173
3200
  const audience = settings == null ? void 0 : settings.audience;
3174
3201
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
@@ -3194,6 +3221,36 @@ var mailchimp_default2 = {
3194
3221
  },
3195
3222
  token
3196
3223
  });
3224
+ if (segments && read) {
3225
+ const owned = await read.aggregate({
3226
+ collection: "segment",
3227
+ pipeline: [
3228
+ {
3229
+ $match: {
3230
+ organization: connection2 == null ? void 0 : connection2.organization,
3231
+ system: { $ne: true }
3232
+ }
3233
+ },
3234
+ {
3235
+ $project: { _id: 0, title: 1 }
3236
+ }
3237
+ ]
3238
+ });
3239
+ const joined = new Set(segments.map((entry) => entry.title));
3240
+ const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3241
+ name: "Drawbridge: " + title,
3242
+ status: joined.has(title) ? "active" : "inactive"
3243
+ }));
3244
+ if (tags.length > 0) {
3245
+ await api3("/lists/" + audience + "/members/" + hash + "/tags", {
3246
+ dc: settings == null ? void 0 : settings.dc,
3247
+ fetcher,
3248
+ method: "POST",
3249
+ payload: { tags },
3250
+ token
3251
+ });
3252
+ }
3253
+ }
3197
3254
  return {
3198
3255
  // Merged into `context` for later steps in this run.
3199
3256
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
@@ -4054,7 +4111,9 @@ var shopify_default2 = {
4054
4111
  enqueues: [{
4055
4112
  data: { product: productId, providerId, shop: connection2.shop },
4056
4113
  name: "workflow",
4057
- options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
4114
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4115
+ // colon in it, and every gid carries two.
4116
+ options: { jobId: "product.workflow.shopify." + context.id + "." + Date.now() },
4058
4117
  queue: "product.shopify"
4059
4118
  }],
4060
4119
  message: "Product sync queued from Shopify webhook.",
@@ -4860,9 +4919,14 @@ var webhook_default = {
4860
4919
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
4861
4920
  ],
4862
4921
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
4922
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
4923
+ // following this had nowhere to put the endpoint they came to configure —
4924
+ // the destination lives on the step (see steps.webhook.send.settings), and
4925
+ // the guide never said so.
4863
4926
  guide: [
4864
4927
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4865
4928
  "Copy the secret into your own endpoint.",
4929
+ "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4866
4930
  "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
4867
4931
  ]
4868
4932
  },
@@ -245,6 +245,7 @@ var HOOK_PROPS = Object.freeze([
245
245
  "scope",
246
246
  "search",
247
247
  "secret",
248
+ "segments",
248
249
  "settings",
249
250
  "sort",
250
251
  "step",
@@ -2574,10 +2575,15 @@ var klaviyo_default2 = {
2574
2575
  excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
2575
2576
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2576
2577
  // rendered — four useful instructions no component displayed.
2578
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2579
+ // list chosen sits at Pending, and this told the merchant it would show
2580
+ // Active. The one required action was the one action the guide omitted, so
2581
+ // anybody following it landed on a connection that looked broken.
2577
2582
  guide: [
2578
2583
  "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
2579
2584
  "Sign in to Klaviyo if you are not already, and choose the account to connect.",
2580
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
2585
+ "Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.",
2586
+ "The connection shows Pending until you pick a list, then Active.",
2581
2587
  "You can revoke access at any time from Klaviyo, under Integrations."
2582
2588
  ]
2583
2589
  },
@@ -2699,14 +2705,15 @@ var klaviyo_default2 = {
2699
2705
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2700
2706
  // different thing from deleting the profile.
2701
2707
  remove: false,
2702
- sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
2708
+ sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
2703
2709
  var _a, _b, _c;
2704
2710
  const list = settings == null ? void 0 : settings.list;
2705
2711
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
2706
2712
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
2707
2713
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2708
- const totals = (contact == null ? void 0 : contact.totals) || {};
2709
- const profile = await api2("/profiles/", {
2714
+ const person = (context == null ? void 0 : context.contact) || null;
2715
+ const totals = (person == null ? void 0 : person.totals) || {};
2716
+ const profile = await api2("/profile-import", {
2710
2717
  fetcher,
2711
2718
  method: "POST",
2712
2719
  payload: {
@@ -2715,16 +2722,36 @@ var klaviyo_default2 = {
2715
2722
  email,
2716
2723
  ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
2717
2724
  properties: {
2718
- drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
2719
- drawbridge_draws: totals.draws || 0,
2720
- drawbridge_entries: totals.entries || 0,
2721
- drawbridge_orders: totals.orders || 0,
2722
- // Campaign-attributed, NOT lifetime. A merchant running
2723
- // Shopify already has lifetime revenue in Klaviyo through
2724
- // Klaviyo's own integration; what only we can say is how
2725
- // much a campaign drove. Named so the two cannot be
2726
- // mistaken for one another in a segment builder.
2727
- drawbridge_revenue: totals.gross || 0
2725
+ ...person && {
2726
+ drawbridge_campaigns: (person.campaigns || []).length,
2727
+ drawbridge_draws: totals.draws || 0,
2728
+ drawbridge_entries: totals.entries || 0,
2729
+ drawbridge_orders: totals.orders || 0,
2730
+ // Campaign-attributed, NOT lifetime. A merchant running
2731
+ // Shopify already has lifetime revenue in Klaviyo through
2732
+ // Klaviyo's own integration; what only we can say is how
2733
+ // much a campaign drove. Named so the two cannot be
2734
+ // mistaken for one another in a segment builder.
2735
+ drawbridge_revenue: totals.gross || 0
2736
+ },
2737
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
2738
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
2739
+ // writable membership — its segments are computed from rules,
2740
+ // so a profile cannot be put in one — and this is the nearest
2741
+ // honest equivalent.
2742
+ //
2743
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
2744
+ // segments are dynamic and nothing dispatches a leave event —
2745
+ // only segment.contact.add exists — so a property that was
2746
+ // only ever appended to would outlive the membership that
2747
+ // earned it. Rewriting the whole array each run means every
2748
+ // sync corrects whatever the last one got wrong, with no
2749
+ // removal path to build.
2750
+ //
2751
+ // `segments` is null when the run carried no contact document,
2752
+ // meaning nobody looked — different from [], which means they
2753
+ // are in none. Null omits the key and merge leaves it alone.
2754
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
2728
2755
  }
2729
2756
  },
2730
2757
  type: "profile"
@@ -3016,7 +3043,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3016
3043
  { status: response.status }
3017
3044
  );
3018
3045
  }
3019
- return response.json();
3046
+ return response.status === 204 ? null : response.json();
3020
3047
  };
3021
3048
  var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3022
3049
  var mailchimp_default2 = {
@@ -3150,7 +3177,7 @@ var mailchimp_default2 = {
3150
3177
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3151
3178
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3152
3179
  // Marketing API reference for the list-members resource.
3153
- sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
3180
+ sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3154
3181
  var _a, _b;
3155
3182
  const audience = settings == null ? void 0 : settings.audience;
3156
3183
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
@@ -3176,6 +3203,36 @@ var mailchimp_default2 = {
3176
3203
  },
3177
3204
  token
3178
3205
  });
3206
+ if (segments && read) {
3207
+ const owned = await read.aggregate({
3208
+ collection: "segment",
3209
+ pipeline: [
3210
+ {
3211
+ $match: {
3212
+ organization: connection2 == null ? void 0 : connection2.organization,
3213
+ system: { $ne: true }
3214
+ }
3215
+ },
3216
+ {
3217
+ $project: { _id: 0, title: 1 }
3218
+ }
3219
+ ]
3220
+ });
3221
+ const joined = new Set(segments.map((entry) => entry.title));
3222
+ const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3223
+ name: "Drawbridge: " + title,
3224
+ status: joined.has(title) ? "active" : "inactive"
3225
+ }));
3226
+ if (tags.length > 0) {
3227
+ await api3("/lists/" + audience + "/members/" + hash + "/tags", {
3228
+ dc: settings == null ? void 0 : settings.dc,
3229
+ fetcher,
3230
+ method: "POST",
3231
+ payload: { tags },
3232
+ token
3233
+ });
3234
+ }
3235
+ }
3179
3236
  return {
3180
3237
  // Merged into `context` for later steps in this run.
3181
3238
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
@@ -4055,7 +4112,9 @@ var shopify_default2 = {
4055
4112
  enqueues: [{
4056
4113
  data: { product: productId, providerId, shop: connection2.shop },
4057
4114
  name: "workflow",
4058
- options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
4115
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4116
+ // colon in it, and every gid carries two.
4117
+ options: { jobId: "product.workflow.shopify." + context.id + "." + Date.now() },
4059
4118
  queue: "product.shopify"
4060
4119
  }],
4061
4120
  message: "Product sync queued from Shopify webhook.",
@@ -4861,9 +4920,14 @@ var webhook_default = {
4861
4920
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
4862
4921
  ],
4863
4922
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
4923
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
4924
+ // following this had nowhere to put the endpoint they came to configure —
4925
+ // the destination lives on the step (see steps.webhook.send.settings), and
4926
+ // the guide never said so.
4864
4927
  guide: [
4865
4928
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4866
4929
  "Copy the secret into your own endpoint.",
4930
+ "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4867
4931
  "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
4868
4932
  ]
4869
4933
  },
package/dist/providers.js CHANGED
@@ -199,6 +199,7 @@ var HOOK_PROPS = Object.freeze([
199
199
  "scope",
200
200
  "search",
201
201
  "secret",
202
+ "segments",
202
203
  "settings",
203
204
  "sort",
204
205
  "step",
@@ -2528,10 +2529,15 @@ var klaviyo_default2 = {
2528
2529
  excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
2529
2530
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2530
2531
  // rendered — four useful instructions no component displayed.
2532
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2533
+ // list chosen sits at Pending, and this told the merchant it would show
2534
+ // Active. The one required action was the one action the guide omitted, so
2535
+ // anybody following it landed on a connection that looked broken.
2531
2536
  guide: [
2532
2537
  "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
2533
2538
  "Sign in to Klaviyo if you are not already, and choose the account to connect.",
2534
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
2539
+ "Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.",
2540
+ "The connection shows Pending until you pick a list, then Active.",
2535
2541
  "You can revoke access at any time from Klaviyo, under Integrations."
2536
2542
  ]
2537
2543
  },
@@ -2653,14 +2659,15 @@ var klaviyo_default2 = {
2653
2659
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2654
2660
  // different thing from deleting the profile.
2655
2661
  remove: false,
2656
- sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
2662
+ sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
2657
2663
  var _a, _b, _c;
2658
2664
  const list = settings == null ? void 0 : settings.list;
2659
2665
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
2660
2666
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
2661
2667
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2662
- const totals = (contact == null ? void 0 : contact.totals) || {};
2663
- const profile = await api2("/profiles/", {
2668
+ const person = (context == null ? void 0 : context.contact) || null;
2669
+ const totals = (person == null ? void 0 : person.totals) || {};
2670
+ const profile = await api2("/profile-import", {
2664
2671
  fetcher,
2665
2672
  method: "POST",
2666
2673
  payload: {
@@ -2669,16 +2676,36 @@ var klaviyo_default2 = {
2669
2676
  email,
2670
2677
  ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
2671
2678
  properties: {
2672
- drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
2673
- drawbridge_draws: totals.draws || 0,
2674
- drawbridge_entries: totals.entries || 0,
2675
- drawbridge_orders: totals.orders || 0,
2676
- // Campaign-attributed, NOT lifetime. A merchant running
2677
- // Shopify already has lifetime revenue in Klaviyo through
2678
- // Klaviyo's own integration; what only we can say is how
2679
- // much a campaign drove. Named so the two cannot be
2680
- // mistaken for one another in a segment builder.
2681
- drawbridge_revenue: totals.gross || 0
2679
+ ...person && {
2680
+ drawbridge_campaigns: (person.campaigns || []).length,
2681
+ drawbridge_draws: totals.draws || 0,
2682
+ drawbridge_entries: totals.entries || 0,
2683
+ drawbridge_orders: totals.orders || 0,
2684
+ // Campaign-attributed, NOT lifetime. A merchant running
2685
+ // Shopify already has lifetime revenue in Klaviyo through
2686
+ // Klaviyo's own integration; what only we can say is how
2687
+ // much a campaign drove. Named so the two cannot be
2688
+ // mistaken for one another in a segment builder.
2689
+ drawbridge_revenue: totals.gross || 0
2690
+ },
2691
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
2692
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
2693
+ // writable membership — its segments are computed from rules,
2694
+ // so a profile cannot be put in one — and this is the nearest
2695
+ // honest equivalent.
2696
+ //
2697
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
2698
+ // segments are dynamic and nothing dispatches a leave event —
2699
+ // only segment.contact.add exists — so a property that was
2700
+ // only ever appended to would outlive the membership that
2701
+ // earned it. Rewriting the whole array each run means every
2702
+ // sync corrects whatever the last one got wrong, with no
2703
+ // removal path to build.
2704
+ //
2705
+ // `segments` is null when the run carried no contact document,
2706
+ // meaning nobody looked — different from [], which means they
2707
+ // are in none. Null omits the key and merge leaves it alone.
2708
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
2682
2709
  }
2683
2710
  },
2684
2711
  type: "profile"
@@ -2970,7 +2997,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
2970
2997
  { status: response.status }
2971
2998
  );
2972
2999
  }
2973
- return response.json();
3000
+ return response.status === 204 ? null : response.json();
2974
3001
  };
2975
3002
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
2976
3003
  var mailchimp_default2 = {
@@ -3104,7 +3131,7 @@ var mailchimp_default2 = {
3104
3131
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3105
3132
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3106
3133
  // Marketing API reference for the list-members resource.
3107
- sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
3134
+ sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3108
3135
  var _a, _b;
3109
3136
  const audience = settings == null ? void 0 : settings.audience;
3110
3137
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
@@ -3130,6 +3157,36 @@ var mailchimp_default2 = {
3130
3157
  },
3131
3158
  token
3132
3159
  });
3160
+ if (segments && read) {
3161
+ const owned = await read.aggregate({
3162
+ collection: "segment",
3163
+ pipeline: [
3164
+ {
3165
+ $match: {
3166
+ organization: connection2 == null ? void 0 : connection2.organization,
3167
+ system: { $ne: true }
3168
+ }
3169
+ },
3170
+ {
3171
+ $project: { _id: 0, title: 1 }
3172
+ }
3173
+ ]
3174
+ });
3175
+ const joined = new Set(segments.map((entry) => entry.title));
3176
+ const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3177
+ name: "Drawbridge: " + title,
3178
+ status: joined.has(title) ? "active" : "inactive"
3179
+ }));
3180
+ if (tags.length > 0) {
3181
+ await api3("/lists/" + audience + "/members/" + hash + "/tags", {
3182
+ dc: settings == null ? void 0 : settings.dc,
3183
+ fetcher,
3184
+ method: "POST",
3185
+ payload: { tags },
3186
+ token
3187
+ });
3188
+ }
3189
+ }
3133
3190
  return {
3134
3191
  // Merged into `context` for later steps in this run.
3135
3192
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
@@ -4009,7 +4066,9 @@ var shopify_default2 = {
4009
4066
  enqueues: [{
4010
4067
  data: { product: productId, providerId, shop: connection2.shop },
4011
4068
  name: "workflow",
4012
- options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
4069
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
4070
+ // colon in it, and every gid carries two.
4071
+ options: { jobId: "product.workflow.shopify." + context.id + "." + Date.now() },
4013
4072
  queue: "product.shopify"
4014
4073
  }],
4015
4074
  message: "Product sync queued from Shopify webhook.",
@@ -4815,9 +4874,14 @@ var webhook_default = {
4815
4874
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
4816
4875
  ],
4817
4876
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
4877
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
4878
+ // following this had nowhere to put the endpoint they came to configure —
4879
+ // the destination lives on the step (see steps.webhook.send.settings), and
4880
+ // the guide never said so.
4818
4881
  guide: [
4819
4882
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4820
4883
  "Copy the secret into your own endpoint.",
4884
+ "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4821
4885
  "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
4822
4886
  ]
4823
4887
  },
package/package.json CHANGED
@@ -216,5 +216,5 @@
216
216
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
217
217
  },
218
218
  "types": "dist/index.d.ts",
219
- "version": "0.0.153"
219
+ "version": "0.0.155"
220
220
  }