@drawbridge/drawbridge-utils 0.0.154 → 0.0.156

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 },
@@ -4938,9 +4995,14 @@ var webhook_default = {
4938
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."
4939
4996
  ],
4940
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.
4941
5002
  guide: [
4942
5003
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4943
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.",
4944
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."
4945
5007
  ]
4946
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
 
@@ -1516,10 +1520,15 @@ const contacts = {
1516
1520
  remove : async ( { email, id, token }, { fetcher } = {} ) => {
1517
1521
 
1518
1522
  // NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
1519
- // difference is what the credential is declared to be: hubspotToken
1520
- // is the one provider field that is not `required`, because this is
1521
- // internal CRM tooling no merchant sees. A deployment with no portal
1522
- // is a supported state, not a missing credential.
1523
+ // difference is what the credential is FOR: this is internal CRM
1524
+ // tooling no merchant sees, so a deployment with no portal is a
1525
+ // supported state rather than a missing credential.
1526
+ //
1527
+ // The manifest does mark hubspotToken `required`, and that is not a
1528
+ // contradiction — the flag feeds `isLive` and nothing else, so it
1529
+ // decides whether the admin card reads live. Not live is the honest
1530
+ // badge when nothing will sync. It gates no behaviour, and
1531
+ // saveProviderSettings never consults it, so the row still writes.
1523
1532
  if( ! token ) return;
1524
1533
 
1525
1534
  const contact = id || await lookup({ email, fetcher, token });
@@ -2834,10 +2843,15 @@ var klaviyo = {
2834
2843
 
2835
2844
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2836
2845
  // rendered — four useful instructions no component displayed.
2846
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2847
+ // list chosen sits at Pending, and this told the merchant it would show
2848
+ // Active. The one required action was the one action the guide omitted, so
2849
+ // anybody following it landed on a connection that looked broken.
2837
2850
  guide : [
2838
2851
  'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
2839
2852
  '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.',
2853
+ 'Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.',
2854
+ 'The connection shows Pending until you pick a list, then Active.',
2841
2855
  'You can revoke access at any time from Klaviyo, under Integrations.'
2842
2856
  ]
2843
2857
 
@@ -2987,7 +3001,7 @@ var klaviyo = {
2987
3001
  // different thing from deleting the profile.
2988
3002
  remove : false,
2989
3003
 
2990
- sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
3004
+ sync : async ( { context, lead, segments, settings, suppressed, token }, { fetcher } = {} ) => {
2991
3005
 
2992
3006
  const list = settings?.list;
2993
3007
 
@@ -3005,9 +3019,32 @@ var klaviyo = {
3005
3019
  // contact knows money: order attribution is person-level and merges
3006
3020
  // across every address a human used, so revenue and orders cannot
3007
3021
  // exist on a single-address record.
3008
- const totals = contact?.totals || {};
3009
-
3010
- const profile = await api$1( '/profiles/', {
3022
+ //
3023
+ // READ OFF `context`, NOT the shell's `contact` prop. That prop is the
3024
+ // ADDRESS a string so `contact?.totals` was undefined on every run
3025
+ // this hook has ever made, and all five properties below shipped as 0
3026
+ // no matter what the person had entered, drawn or spent. Only a
3027
+ // segment.contact.add run carries the document (drawbridge-sync
3028
+ // lib/segment.js); lead.insert names a brand-new entrant who has no
3029
+ // history to send anyway.
3030
+ const person = context?.contact || null;
3031
+ const totals = person?.totals || {};
3032
+
3033
+ // UPSERT, NOT CREATE. /profiles/ is the plain create and answers a
3034
+ // conflict for a profile Klaviyo already holds — which is most of them,
3035
+ // because the whole point of the segment trigger is syncing people who
3036
+ // have been around long enough to earn a ranking. profile-import is
3037
+ // Klaviyo's documented create-or-update: 201 when it made one, 200 when
3038
+ // it updated one, and no conflict either way
3039
+ // (developers.klaviyo.com/en/reference/create_or_update_profile,
3040
+ // fetched 2026-09-09).
3041
+ //
3042
+ // IT MERGES, and that is why the blocks below are conditional rather
3043
+ // than defaulted. "Not including a field in your request will leave it
3044
+ // unchanged" — so omitting the totals on a lead.insert preserves
3045
+ // whatever the last segment run published, where sending zeros would
3046
+ // erase it. A property we cannot compute is one we must not write.
3047
+ const profile = await api$1( '/profile-import', {
3011
3048
  fetcher,
3012
3049
  method : 'POST',
3013
3050
  payload : {
@@ -3016,16 +3053,36 @@ var klaviyo = {
3016
3053
  email,
3017
3054
  ...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
3018
3055
  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
3056
+ ...( person && {
3057
+ drawbridge_campaigns : ( person.campaigns || [] ).length,
3058
+ drawbridge_draws : totals.draws || 0,
3059
+ drawbridge_entries : totals.entries || 0,
3060
+ drawbridge_orders : totals.orders || 0,
3061
+ // Campaign-attributed, NOT lifetime. A merchant running
3062
+ // Shopify already has lifetime revenue in Klaviyo through
3063
+ // Klaviyo's own integration; what only we can say is how
3064
+ // much a campaign drove. Named so the two cannot be
3065
+ // mistaken for one another in a segment builder.
3066
+ drawbridge_revenue : totals.gross || 0
3067
+ }),
3068
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3069
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
3070
+ // writable membership — its segments are computed from rules,
3071
+ // so a profile cannot be put in one — and this is the nearest
3072
+ // honest equivalent.
3073
+ //
3074
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
3075
+ // segments are dynamic and nothing dispatches a leave event —
3076
+ // only segment.contact.add exists — so a property that was
3077
+ // only ever appended to would outlive the membership that
3078
+ // earned it. Rewriting the whole array each run means every
3079
+ // sync corrects whatever the last one got wrong, with no
3080
+ // removal path to build.
3081
+ //
3082
+ // `segments` is null when the run carried no contact document,
3083
+ // meaning nobody looked — different from [], which means they
3084
+ // are in none. Null omits the key and merge leaves it alone.
3085
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3029
3086
  }
3030
3087
  },
3031
3088
  type : 'profile'
@@ -3427,7 +3484,10 @@ const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token
3427
3484
 
3428
3485
  }
3429
3486
 
3430
- return response.json();
3487
+ // 204 ON A TAG WRITE — nothing to parse, and asking an empty body for json
3488
+ // throws, which would report a change Mailchimp accepted as a failed step.
3489
+ // Klaviyo's file carries the same guard for the same reason.
3490
+ return response.status === 204 ? null : response.json();
3431
3491
 
3432
3492
  };
3433
3493
 
@@ -3591,7 +3651,7 @@ var mailchimp = {
3591
3651
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3592
3652
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3593
3653
  // Marketing API reference for the list-members resource.
3594
- sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
3654
+ sync : async ( { connection, lead, segments, settings, suppressed, token }, { fetcher, read } = {} ) => {
3595
3655
 
3596
3656
  const audience = settings?.audience;
3597
3657
 
@@ -3648,6 +3708,75 @@ var mailchimp = {
3648
3708
  token
3649
3709
  });
3650
3710
 
3711
+ // THE DRAWBRIDGE SEGMENTS THIS PERSON IS IN, written as Mailchimp tags.
3712
+ //
3713
+ // TAGS, NOT MERGE FIELDS. A merge field has to exist on the audience
3714
+ // before anything can be written to it, and an unregistered one is
3715
+ // refused along with the whole member request — so carrying data that
3716
+ // way means registering fields first, which is lifecycle.register's job
3717
+ // and is not built. A tag needs no setup: "If a tag that does not exist
3718
+ // is passed in and set as 'active', a new tag will be created"
3719
+ // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
3720
+ // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
3721
+ // to explain in the guide.
3722
+ //
3723
+ // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
3724
+ // over time. Drawbridge segments are dynamic, and nothing dispatches a
3725
+ // leave event — only segment.contact.add exists — so a tag that was
3726
+ // only ever added would outlive the membership behind it and the
3727
+ // merchant would target a condition that had stopped being true.
3728
+ // Sending the whole list every time, each tag active or inactive
3729
+ // against current membership, means each sync corrects the last.
3730
+ //
3731
+ // WRITTEN FOR A SUPPRESSED PERSON TOO. A tag is a label, not a
3732
+ // permission: the `status : 'unsubscribed'` above is what stops mail
3733
+ // reaching them, and letting their labels rot would leave the merchant
3734
+ // a wrong answer about who belongs to what.
3735
+ //
3736
+ // PREFIXED, so a Drawbridge tag cannot collide with one the merchant
3737
+ // keeps by hand, and so the inactive pass can only clear tags this
3738
+ // integration wrote.
3739
+ if( segments && read ){
3740
+
3741
+ const owned = await read.aggregate({
3742
+ collection : 'segment',
3743
+ pipeline : [
3744
+ {
3745
+ $match : {
3746
+ organization : connection?.organization,
3747
+ system : { $ne : true }
3748
+ }
3749
+ },
3750
+ {
3751
+ $project : { _id : 0, title : 1 }
3752
+ }
3753
+ ]
3754
+ });
3755
+
3756
+ const joined = new Set( segments.map( ( entry ) => entry.title ) );
3757
+
3758
+ const tags = ( owned || [] )
3759
+ .map( ( entry ) => entry.title )
3760
+ .filter( Boolean )
3761
+ .map( ( title ) => ({
3762
+ name : 'Drawbridge: ' + title,
3763
+ status : joined.has( title ) ? 'active' : 'inactive'
3764
+ }) );
3765
+
3766
+ if( tags.length > 0 ){
3767
+
3768
+ await api( '/lists/' + audience + '/members/' + hash + '/tags', {
3769
+ dc : settings?.dc,
3770
+ fetcher,
3771
+ method : 'POST',
3772
+ payload : { tags },
3773
+ token
3774
+ });
3775
+
3776
+ }
3777
+
3778
+ }
3779
+
3651
3780
  return {
3652
3781
  // Merged into `context` for later steps in this run.
3653
3782
  context : { mailchimpMemberId : member?.id || hash },
@@ -5765,9 +5894,14 @@ var webhook = {
5765
5894
  '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.'
5766
5895
  ],
5767
5896
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
5897
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
5898
+ // following this had nowhere to put the endpoint they came to configure —
5899
+ // the destination lives on the step (see steps.webhook.send.settings), and
5900
+ // the guide never said so.
5768
5901
  guide : [
5769
5902
  'Press Connect. Drawbridge generates a signing secret and shows it here.',
5770
5903
  'Copy the secret into your own endpoint.',
5904
+ '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.',
5771
5905
  '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.'
5772
5906
  ]
5773
5907
  },