@drawbridge/drawbridge-utils 0.0.171 → 0.0.173

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.
@@ -854,6 +854,13 @@ const row = ({ connection, data, manifest, row : described }) => ({
854
854
 
855
855
  const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
856
856
 
857
+ // NO ID, NO ROW. `String( undefined )` is the string 'undefined', which is a
858
+ // perfectly valid string as far as the api's schema is concerned — so a row
859
+ // built without a vendor id would store and then render as a live-looking
860
+ // link to an object that does not exist. The three hooks all guard before
861
+ // they get here; this is the guard that does not depend on them remembering.
862
+ if( described?.id === undefined || described?.id === null ) return [];
863
+
857
864
  const built = row({ connection, data, manifest, row : described });
858
865
 
859
866
  return [
@@ -3495,6 +3502,39 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3495
3502
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
3496
3503
  </svg>`;
3497
3504
 
3505
+ // WHAT A GRANT IS MISSING, as set arithmetic and nothing else.
3506
+ //
3507
+ // Every vendor that can drift answers `auth.scopes` with what is absent from the
3508
+ // grant it was handed. The comparison itself is the same everywhere — which
3509
+ // strings did we ask for, which strings came back — so it lives here rather than
3510
+ // being retyped per manifest, where each copy is a chance to split on the wrong
3511
+ // character or forget to filter the empty string.
3512
+ //
3513
+ // SPACE SEPARATED IN, ARRAY OUT. OAuth scope strings are space separated by
3514
+ // RFC 6749 §3.3, and both halves are tolerated as arrays because a vendor SDK may
3515
+ // already have parsed one.
3516
+ //
3517
+ // AN EMPTY ANSWER IS NOT THE SAME AS AN UNKNOWN ONE. A caller with no grant
3518
+ // string has learned nothing and must not read "nothing missing" from that — so
3519
+ // an absent `granted` answers null, and only a grant we actually read answers a
3520
+ // list. Getting this wrong reports every connection healthy the moment a read
3521
+ // fails, which is the silent direction.
3522
+ const list = ( value ) => (
3523
+ Array.isArray( value )
3524
+ ? value.flatMap( ( entry ) => String( entry ).split( /\s+/ ) )
3525
+ : String( value ?? '' ).split( /\s+/ )
3526
+ ).map( ( entry ) => entry.trim() ).filter( Boolean );
3527
+
3528
+ const missingScopes = ({ granted, required }) => {
3529
+
3530
+ if( granted === null || granted === undefined || granted === '' ) return null;
3531
+
3532
+ const held = new Set( list( granted ) );
3533
+
3534
+ return list( required ).filter( ( scope ) => ! held.has( scope ) );
3535
+
3536
+ };
3537
+
3498
3538
  const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
3499
3539
 
3500
3540
  const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
@@ -3531,22 +3571,59 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3531
3571
 
3532
3572
  // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3533
3573
  // below keys on the id — so a rename never has to touch a profile.
3534
- const segmentName = ( title ) => 'Drawbridge: ' + title;
3535
-
3536
- // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3537
- // only what the merchant consented to, so a connection made before segments were
3538
- // asked for holds one that cannot write a segment — Create, Update and Delete
3539
- // Segment each list `segments:write`
3540
- // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3541
- // 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
3542
- // nothing; this does.
3543
3574
  //
3544
- // `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
3545
- // describes as "The scopes that this access token has access to for accessing
3546
- // API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
3547
- // 2026-09-11) not the list this manifest asks for, which is why a connection
3548
- // older than the ask reads as false rather than true.
3549
- const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
3575
+ // THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
3576
+ // alone, a second segment called "VIP" found the first one's Klaviyo segment by
3577
+ // name and adopted it: both rows then pointed at one segment whose definition
3578
+ // computes the FIRST one's membership, so the merchant sent to the wrong people,
3579
+ // and deleting either segment deleted that object out from under the other with
3580
+ // nothing to repair it.
3581
+ //
3582
+ // The api now carries a unique index on (organization, title) and a 409 on both
3583
+ // create and rename, so that pair can no longer be created. The suffix stays
3584
+ // anyway: it is what makes the damage impossible rather than merely unlikely,
3585
+ // and it is the only part of this that holds if a title ever reaches Klaviyo
3586
+ // from somewhere that did not go through the route.
3587
+ //
3588
+ // KLAVIYO IS NOT DOING THIS FOR US. Create Segment describes `name` only as "A
3589
+ // helpful name to label the segment" and documents no uniqueness constraint and
3590
+ // no duplicate-name error (developers.klaviyo.com/en/reference/create_segment,
3591
+ // fetched 2026-09-12), so the search cannot lean on the vendor. What IS
3592
+ // documented is that filter comparisons are case-sensitive
3593
+ // (developers.klaviyo.com/en/docs/filtering_, same date), which is why the
3594
+ // find-by-name below re-checks with === and agrees with the vendor rather than
3595
+ // guessing.
3596
+ const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
3597
+
3598
+ // `scope` on the stored settings is the grant Klaviyo RETURNED on the exchange,
3599
+ // which its OAuth guide describes as "The scopes that this access token has
3600
+ // access to for accessing API resources"
3601
+ // (developers.klaviyo.com/en/docs/set_up_oauth, fetched 2026-09-11) — not the
3602
+ // list this manifest asks for. That difference is the whole drift: a connection
3603
+ // older than the ask holds a narrower grant and nothing says so.
3604
+ //
3605
+ // THE SCOPES THIS APP ASKS FOR, named once. The consent url and the drift check
3606
+ // read the same constant, so a scope added to one is never missing from the
3607
+ // other — which is exactly how a grant ends up unable to do something nobody
3608
+ // noticed it could no longer do.
3609
+ //
3610
+ // Space separated. accounts:read is required by Klaviyo on every app; the rest
3611
+ // are what a contact sync and the segment hooks need — Get Segments lists
3612
+ // `segments:read`, and Create, Update and Delete Segment each list
3613
+ // `segments:write`
3614
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3615
+ // 2026-07-15, fetched 2026-09-11).
3616
+ const SCOPES = 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write';
3617
+
3618
+ // WHAT THIS GRANT CANNOT DO, through the shared comparison every vendor uses.
3619
+ // Answers null when there is no grant string to judge, which is not the same as
3620
+ // "nothing missing" — see lib/connections/scopes.js.
3621
+ const missing = ( settings ) => missingScopes({ granted : settings?.scope, required : SCOPES });
3622
+
3623
+ // The register and remove hooks hard-skip on this rather than calling Klaviyo
3624
+ // and reading a 403 back. It asks the same question the drift check does, of the
3625
+ // same constant.
3626
+ const canManageSegments = ( settings ) => ! ( missing( settings ) || [] ).includes( 'segments:write' );
3550
3627
 
3551
3628
  // Klaviyo — contact sync, over OAuth.
3552
3629
  //
@@ -3611,7 +3688,7 @@ var klaviyo = {
3611
3688
  // Update and Delete Segment each list `segments:write`
3612
3689
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3613
3690
  // revision 2026-07-15, fetched 2026-09-11).
3614
- scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
3691
+ scopes : SCOPES,
3615
3692
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3616
3693
  // the disconnect hook — three vendor addresses, two of them declared,
3617
3694
  // which is exactly the kind of split that goes unnoticed.
@@ -3818,8 +3895,14 @@ var klaviyo = {
3818
3895
 
3819
3896
  },
3820
3897
 
3821
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3822
- scopes : false,
3898
+ // THEY DO DRIFT, and the comment here used to say they could not. Klaviyo
3899
+ // scopes are fixed on the APP, so adding one re-consents every NEW grant
3900
+ // and leaves every EXISTING one exactly as narrow as it was — with no
3901
+ // error, no webhook, and nothing that notices. That is what left grants
3902
+ // authenticating perfectly while silently unable to manage a segment.
3903
+ //
3904
+ // The same slot Shopify answers, so one caller can ask any vendor.
3905
+ scopes : ({ scope }) => missingScopes({ granted : scope, required : SCOPES }),
3823
3906
 
3824
3907
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3825
3908
  // client_id/client_secret pair as body fields. Everything else about the
@@ -4047,7 +4130,7 @@ var klaviyo = {
4047
4130
 
4048
4131
  }
4049
4132
 
4050
- const name = segmentName( segment.title );
4133
+ const name = segmentName( segment.title, segment.id );
4051
4134
  const existing = segmentRowFor({ connection, segment });
4052
4135
 
4053
4136
  let id = null;
@@ -4245,7 +4328,7 @@ var klaviyo = {
4245
4328
  // The one call below proves the minted token is HONOURED — mint and
4246
4329
  // acceptance are different facts, and /accounts is already the call
4247
4330
  // the connect flow makes (auth.connect), so it needs no new scope.
4248
- health : async ( { connection, token }, { fetcher, read } = {} ) => {
4331
+ health : async ( { connection, settings, token }, { fetcher, read } = {} ) => {
4249
4332
 
4250
4333
  const request = { connectionId : connection.id };
4251
4334
 
@@ -4253,6 +4336,10 @@ var klaviyo = {
4253
4336
 
4254
4337
  await api$1( '/accounts', { fetcher, token });
4255
4338
 
4339
+ // SCOPE DRIFT IS NOT CHECKED HERE. The shell does it for every vendor
4340
+ // after this step succeeds, by asking hooks.auth.scopes — see
4341
+ // reconcileScopes in drawbridge-sync lib/step-runner.js. This hook
4342
+ // answers only whether the token was minted and honoured.
4256
4343
  return {
4257
4344
  message : 'Health check passed — token minted and accepted.',
4258
4345
  request,
@@ -4536,13 +4623,14 @@ var klaviyo = {
4536
4623
  // discovers the second. The grant leads because reconnecting is the longer
4537
4624
  // errand.
4538
4625
  return [
4539
- // A connection made before segments were requested is authenticated and
4540
- // cannot manage them, and no error surfaces anywhere else the register
4541
- // runs skip rather than fail.
4542
- ...( canManageSegments( data?.settings ) ? [] : [ {
4543
- message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4544
- title : 'Reconnect Klaviyo'
4545
- } ] ),
4626
+ // THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
4627
+ // written by the health check, because a task is quiet: it renders only in
4628
+ // the body of this connection's own page, so a merchant who never opens it
4629
+ // never learns that their segments stopped being published. An error entry
4630
+ // reaches the card and the organization checklist too.
4631
+ //
4632
+ // Deliberately not a status change — see the health hook for why that
4633
+ // would stop the contact sync that still works.
4546
4634
  ...( data?.settings?.list ? [] : [ {
4547
4635
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
4548
4636
  title : 'Choose a list'
@@ -4657,13 +4745,29 @@ const subscriberHash = ( email ) => createHash( 'md5' )
4657
4745
  // server-side truncation is the worse outcome because register would then look
4658
4746
  // for a name Mailchimp had silently changed.
4659
4747
  //
4660
- // ponytail: two segments whose first 88 characters match would share one tag.
4661
- // The upgrade is to append a short hash of the segment id, which costs the name
4662
- // its readability in the merchant's own audience not worth it until somebody
4663
- // actually collides.
4748
+ // THE SEGMENT ID IS IN THE NAME, and it has to be. Nothing makes a Drawbridge
4749
+ // segment title unique no index on the collection, no check in the route, no
4750
+ // check in the form so two segments called "VIP" are ordinary. Keyed on title
4751
+ // alone, the second one's register finds the first one's tag, adopts it, and
4752
+ // both rows point at one object: the merchant's two segments are one tag, and
4753
+ // deleting either segment deletes the tag out from under the other, leaving a
4754
+ // row pointing at an id that no longer exists and nothing to repair it.
4755
+ //
4756
+ // The last six characters of the id are enough to separate them and short
4757
+ // enough to leave the title readable in the merchant's own audience.
4758
+ //
4759
+ // A rename now changes the name and keeps the suffix, so register's find-by-name
4760
+ // still matches after one — and the truncation below cuts the TITLE rather than
4761
+ // the suffix for the same reason.
4664
4762
  const TAG_NAME_LIMIT = 100;
4665
4763
 
4666
- const tagName = ( title ) => ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT );
4764
+ const tagName = ( title, id ) => {
4765
+
4766
+ const suffix = ' (' + String( id ).slice( -6 ) + ')';
4767
+
4768
+ return ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT - suffix.length ) + suffix;
4769
+
4770
+ };
4667
4771
 
4668
4772
  // Mailchimp — contact sync, not a sender.
4669
4773
  var mailchimp = {
@@ -4958,19 +5062,21 @@ var mailchimp = {
4958
5062
  }
4959
5063
  },
4960
5064
  {
4961
- $project : { _id : 0, title : 1 }
5065
+ // THE ID AS WELL AS THE TITLE, because the tag name carries it —
5066
+ // see tagName. Without it every name here would end in the
5067
+ // string 'undefined' and match nothing register wrote.
5068
+ $project : { _id : 0, id : 1, title : 1 }
4962
5069
  }
4963
5070
  ]
4964
5071
  });
4965
5072
 
4966
- const joined = new Set( segments.map( ( entry ) => entry.title ) );
5073
+ const joined = new Set( segments.map( ( entry ) => entry.id ) );
4967
5074
 
4968
5075
  const tags = ( owned || [] )
4969
- .map( ( entry ) => entry.title )
4970
- .filter( Boolean )
4971
- .map( ( title ) => ({
4972
- name : tagName( title ),
4973
- status : joined.has( title ) ? 'active' : 'inactive'
5076
+ .filter( ( entry ) => entry.id && entry.title )
5077
+ .map( ( entry ) => ({
5078
+ name : tagName( entry.title, entry.id ),
5079
+ status : joined.has( entry.id ) ? 'active' : 'inactive'
4974
5080
  }) );
4975
5081
 
4976
5082
  if( tags.length > 0 ){
@@ -5040,7 +5146,7 @@ var mailchimp = {
5040
5146
 
5041
5147
  if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
5042
5148
 
5043
- const name = tagName( segment.title );
5149
+ const name = tagName( segment.title, segment.id );
5044
5150
  const existing = segmentRowFor({ connection, segment });
5045
5151
 
5046
5152
  let id = null;
@@ -5768,23 +5874,33 @@ var shopify = {
5768
5874
 
5769
5875
  const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
5770
5876
 
5877
+ // KEPT even though nothing below sends the address anywhere. A code
5878
+ // that reaches nobody is a wasted action and a merchant's wasted
5879
+ // allowance: issuing is only half a workflow, and the email step after
5880
+ // it needs somewhere to send the code.
5771
5881
  if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
5772
5882
  if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
5773
5883
  if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
5774
5884
 
5775
5885
  const adminAccessToken = await adminToken();
5776
5886
 
5777
- // The customer must exist before a code is mapped to them. An earlier
5778
- // commerce.customer step usually did this and left the id on the
5779
- // context; when this step runs alone, it does it here.
5780
- if( ! context.shopifyCustomerId ){
5781
-
5782
- const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
5783
-
5784
- if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
5785
-
5786
- }
5787
-
5887
+ // NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
5888
+ // there is nothing to send: discountRedeemCodeBulkAdd takes a discount
5889
+ // and a list of codes, and no customer
5890
+ // (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
5891
+ // The link lives entirely on our side — `lead.shopifyDiscountCode`
5892
+ // below and attribution happens when the order comes back carrying
5893
+ // the code, matched org-scoped against that field by commerce.order.
5894
+ //
5895
+ // This step used to call getOrCreateCustomer first, on the stated
5896
+ // reasoning that "the customer must exist before a code is mapped to
5897
+ // them". No code was ever mapped to them: the result was assigned and
5898
+ // discarded, createDiscountCode never received it, and redemption
5899
+ // attribution never consulted it. All it did was create a customer in
5900
+ // the merchant's store as a side effect of issuing a discount — which
5901
+ // QA reported as surprising, and was right to. A merchant who wants the
5902
+ // buyer to exist at the store adds the Create customer step, which is
5903
+ // what that step is for.
5788
5904
  const discountCode = await shopify.admin.createDiscountCode({
5789
5905
  adminAccessToken,
5790
5906
  code : 'DB-' + generateDiscountCode(),
@@ -542,6 +542,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
542
542
  };
543
543
  };
544
544
  var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
545
+ if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
545
546
  const built = row({ connection: connection2, data: data2, manifest, row: described });
546
547
  return [
547
548
  // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
@@ -2091,16 +2092,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2091
2092
  };
2092
2093
  };
2093
2094
  var summariseEvents = (events) => {
2094
- const list = Array.isArray(events) ? events : [];
2095
- if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2095
+ const list2 = Array.isArray(events) ? events : [];
2096
+ if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2096
2097
  const counts = {};
2097
- for (const { event } of list) {
2098
+ for (const { event } of list2) {
2098
2099
  const name = event || "unknown";
2099
2100
  counts[name] = (counts[name] || 0) + 1;
2100
2101
  }
2101
2102
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2102
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2103
- const writes = list.map((event) => {
2103
+ const refused = list2.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2104
+ const writes = list2.map((event) => {
2104
2105
  var _a;
2105
2106
  return deliveryWrite({
2106
2107
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2120,7 +2121,7 @@ var summariseEvents = (events) => {
2120
2121
  });
2121
2122
  }).filter(Boolean);
2122
2123
  return {
2123
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2124
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2124
2125
  ...writes.length ? { writes } : { skipped: true }
2125
2126
  };
2126
2127
  };
@@ -2987,6 +2988,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
2987
2988
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
2988
2989
  </svg>`;
2989
2990
 
2991
+ // lib/connections/scopes.js
2992
+ var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
2993
+ var missingScopes = ({ granted, required }) => {
2994
+ if (granted === null || granted === void 0 || granted === "") return null;
2995
+ const held = new Set(list(granted));
2996
+ return list(required).filter((scope) => !held.has(scope));
2997
+ };
2998
+
2990
2999
  // lib/connections/providers/klaviyo.js
2991
3000
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
2992
3001
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -3014,8 +3023,10 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3014
3023
  }
3015
3024
  return response.status === 204 ? null : response.json();
3016
3025
  };
3017
- var segmentName = (title) => "Drawbridge: " + title;
3018
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
3026
+ var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3027
+ var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
3028
+ var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
3029
+ var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
3019
3030
  var klaviyo_default2 = {
3020
3031
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
3021
3032
  // exchange without a code_verifier matching the challenge the consent
@@ -3059,7 +3070,7 @@ var klaviyo_default2 = {
3059
3070
  // Update and Delete Segment each list `segments:write`
3060
3071
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3061
3072
  // revision 2026-07-15, fetched 2026-09-11).
3062
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3073
+ scopes: SCOPES,
3063
3074
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3064
3075
  // the disconnect hook — three vendor addresses, two of them declared,
3065
3076
  // which is exactly the kind of split that goes unnoticed.
@@ -3240,8 +3251,14 @@ var klaviyo_default2 = {
3240
3251
  });
3241
3252
  return { ok: Boolean(token) };
3242
3253
  },
3243
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3244
- scopes: false,
3254
+ // THEY DO DRIFT, and the comment here used to say they could not. Klaviyo
3255
+ // scopes are fixed on the APP, so adding one re-consents every NEW grant
3256
+ // and leaves every EXISTING one exactly as narrow as it was — with no
3257
+ // error, no webhook, and nothing that notices. That is what left grants
3258
+ // authenticating perfectly while silently unable to manage a segment.
3259
+ //
3260
+ // The same slot Shopify answers, so one caller can ask any vendor.
3261
+ scopes: ({ scope }) => missingScopes({ granted: scope, required: SCOPES }),
3245
3262
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3246
3263
  // client_id/client_secret pair as body fields. Everything else about the
3247
3264
  // request is standard, so this is the shared implementation told the one
@@ -3262,9 +3279,9 @@ var klaviyo_default2 = {
3262
3279
  remove: false,
3263
3280
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3264
3281
  var _a, _b, _c, _d, _e;
3265
- const list = settings == null ? void 0 : settings.list;
3282
+ const list2 = settings == null ? void 0 : settings.list;
3266
3283
  const request2 = { 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) || null, list: (settings == null ? void 0 : settings.list) || null };
3267
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3284
+ if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3268
3285
  const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
3269
3286
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3270
3287
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3343,7 +3360,7 @@ var klaviyo_default2 = {
3343
3360
  }]
3344
3361
  }
3345
3362
  },
3346
- relationships: { list: { data: { id: list, type: "list" } } },
3363
+ relationships: { list: { data: { id: list2, type: "list" } } },
3347
3364
  type: "profile-subscription-bulk-create-job"
3348
3365
  }
3349
3366
  },
@@ -3389,7 +3406,7 @@ var klaviyo_default2 = {
3389
3406
  skipped: true
3390
3407
  };
3391
3408
  }
3392
- const name = segmentName(segment.title);
3409
+ const name = segmentName(segment.title, segment.id);
3393
3410
  const existing = segmentRowFor({ connection: connection2, segment });
3394
3411
  let id = null;
3395
3412
  if (existing == null ? void 0 : existing.id) {
@@ -3516,7 +3533,7 @@ var klaviyo_default2 = {
3516
3533
  // The one call below proves the minted token is HONOURED — mint and
3517
3534
  // acceptance are different facts, and /accounts is already the call
3518
3535
  // the connect flow makes (auth.connect), so it needs no new scope.
3519
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3536
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3520
3537
  const request2 = { connectionId: connection2.id };
3521
3538
  try {
3522
3539
  await api2("/accounts", { fetcher, token });
@@ -3568,8 +3585,8 @@ var klaviyo_default2 = {
3568
3585
  let pages = 0;
3569
3586
  while (next && audiences.length < limit && pages < 20) {
3570
3587
  const body = await api2(next, { fetcher, token });
3571
- for (const list of (body == null ? void 0 : body.data) || []) {
3572
- audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
3588
+ for (const list2 of (body == null ? void 0 : body.data) || []) {
3589
+ audiences.push({ id: list2.id, title: ((_a = list2 == null ? void 0 : list2.attributes) == null ? void 0 : _a.name) || list2.id });
3573
3590
  }
3574
3591
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3575
3592
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3735,13 +3752,14 @@ var klaviyo_default2 = {
3735
3752
  var _a;
3736
3753
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3737
3754
  return [
3738
- // A connection made before segments were requested is authenticated and
3739
- // cannot manage them, and no error surfaces anywhere else the register
3740
- // runs skip rather than fail.
3741
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3742
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3743
- title: "Reconnect Klaviyo"
3744
- }],
3755
+ // THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
3756
+ // written by the health check, because a task is quiet: it renders only in
3757
+ // the body of this connection's own page, so a merchant who never opens it
3758
+ // never learns that their segments stopped being published. An error entry
3759
+ // reaches the card and the organization checklist too.
3760
+ //
3761
+ // Deliberately not a status change — see the health hook for why that
3762
+ // would stop the contact sync that still works.
3745
3763
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3746
3764
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3747
3765
  title: "Choose a list"
@@ -3796,7 +3814,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3796
3814
  };
3797
3815
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3798
3816
  var TAG_NAME_LIMIT = 100;
3799
- var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
3817
+ var tagName = (title, id) => {
3818
+ const suffix = " (" + String(id).slice(-6) + ")";
3819
+ return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
3820
+ };
3800
3821
  var mailchimp_default2 = {
3801
3822
  // OAUTH 2, authorization code. Every url below is quoted from
3802
3823
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3977,14 +3998,17 @@ var mailchimp_default2 = {
3977
3998
  }
3978
3999
  },
3979
4000
  {
3980
- $project: { _id: 0, title: 1 }
4001
+ // THE ID AS WELL AS THE TITLE, because the tag name carries it —
4002
+ // see tagName. Without it every name here would end in the
4003
+ // string 'undefined' and match nothing register wrote.
4004
+ $project: { _id: 0, id: 1, title: 1 }
3981
4005
  }
3982
4006
  ]
3983
4007
  });
3984
- const joined = new Set(segments.map((entry) => entry.title));
3985
- const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3986
- name: tagName(title),
3987
- status: joined.has(title) ? "active" : "inactive"
4008
+ const joined = new Set(segments.map((entry) => entry.id));
4009
+ const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
4010
+ name: tagName(entry.title, entry.id),
4011
+ status: joined.has(entry.id) ? "active" : "inactive"
3988
4012
  }));
3989
4013
  if (tags.length > 0) {
3990
4014
  await api3("/lists/" + audience + "/members/" + hash + "/tags", {
@@ -4036,7 +4060,7 @@ var mailchimp_default2 = {
4036
4060
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4037
4061
  const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4038
4062
  if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4039
- const name = tagName(segment.title);
4063
+ const name = tagName(segment.title, segment.id);
4040
4064
  const existing = segmentRowFor({ connection: connection2, segment });
4041
4065
  let id = null;
4042
4066
  if (existing == null ? void 0 : existing.id) {
@@ -4142,7 +4166,7 @@ var mailchimp_default2 = {
4142
4166
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4143
4167
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4144
4168
  );
4145
- const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
4169
+ const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
4146
4170
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4147
4171
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4148
4172
  const nextOffset = offset + count;
@@ -4589,10 +4613,6 @@ var shopify_default2 = {
4589
4613
  if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
4590
4614
  if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
4591
4615
  const adminAccessToken = await adminToken();
4592
- if (!context.shopifyCustomerId) {
4593
- const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
4594
- if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
4595
- }
4596
4616
  const discountCode = await shopify.admin.createDiscountCode({
4597
4617
  adminAccessToken,
4598
4618
  code: "DB-" + generateDiscountCode(),