@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.
@@ -618,6 +618,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
618
618
  };
619
619
  };
620
620
  var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
621
+ if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
621
622
  const built = row({ connection: connection2, data: data2, manifest, row: described });
622
623
  return [
623
624
  // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
@@ -2167,16 +2168,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2167
2168
  };
2168
2169
  };
2169
2170
  var summariseEvents = (events) => {
2170
- const list = Array.isArray(events) ? events : [];
2171
- if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2171
+ const list2 = Array.isArray(events) ? events : [];
2172
+ if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2172
2173
  const counts = {};
2173
- for (const { event } of list) {
2174
+ for (const { event } of list2) {
2174
2175
  const name = event || "unknown";
2175
2176
  counts[name] = (counts[name] || 0) + 1;
2176
2177
  }
2177
2178
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2178
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2179
- const writes = list.map((event) => {
2179
+ const refused = list2.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2180
+ const writes = list2.map((event) => {
2180
2181
  var _a;
2181
2182
  return deliveryWrite({
2182
2183
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2196,7 +2197,7 @@ var summariseEvents = (events) => {
2196
2197
  });
2197
2198
  }).filter(Boolean);
2198
2199
  return {
2199
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2200
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2200
2201
  ...writes.length ? { writes } : { skipped: true }
2201
2202
  };
2202
2203
  };
@@ -3063,6 +3064,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3063
3064
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
3064
3065
  </svg>`;
3065
3066
 
3067
+ // lib/connections/scopes.js
3068
+ var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3069
+ var missingScopes = ({ granted, required }) => {
3070
+ if (granted === null || granted === void 0 || granted === "") return null;
3071
+ const held = new Set(list(granted));
3072
+ return list(required).filter((scope) => !held.has(scope));
3073
+ };
3074
+
3066
3075
  // lib/connections/providers/klaviyo.js
3067
3076
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
3068
3077
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -3090,8 +3099,10 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3090
3099
  }
3091
3100
  return response.status === 204 ? null : response.json();
3092
3101
  };
3093
- var segmentName = (title) => "Drawbridge: " + title;
3094
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
3102
+ var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3103
+ var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
3104
+ var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
3105
+ var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
3095
3106
  var klaviyo_default2 = {
3096
3107
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
3097
3108
  // exchange without a code_verifier matching the challenge the consent
@@ -3135,7 +3146,7 @@ var klaviyo_default2 = {
3135
3146
  // Update and Delete Segment each list `segments:write`
3136
3147
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3137
3148
  // revision 2026-07-15, fetched 2026-09-11).
3138
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3149
+ scopes: SCOPES,
3139
3150
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3140
3151
  // the disconnect hook — three vendor addresses, two of them declared,
3141
3152
  // which is exactly the kind of split that goes unnoticed.
@@ -3316,8 +3327,14 @@ var klaviyo_default2 = {
3316
3327
  });
3317
3328
  return { ok: Boolean(token) };
3318
3329
  },
3319
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3320
- scopes: false,
3330
+ // THEY DO DRIFT, and the comment here used to say they could not. Klaviyo
3331
+ // scopes are fixed on the APP, so adding one re-consents every NEW grant
3332
+ // and leaves every EXISTING one exactly as narrow as it was — with no
3333
+ // error, no webhook, and nothing that notices. That is what left grants
3334
+ // authenticating perfectly while silently unable to manage a segment.
3335
+ //
3336
+ // The same slot Shopify answers, so one caller can ask any vendor.
3337
+ scopes: ({ scope }) => missingScopes({ granted: scope, required: SCOPES }),
3321
3338
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3322
3339
  // client_id/client_secret pair as body fields. Everything else about the
3323
3340
  // request is standard, so this is the shared implementation told the one
@@ -3338,9 +3355,9 @@ var klaviyo_default2 = {
3338
3355
  remove: false,
3339
3356
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3340
3357
  var _a, _b, _c, _d, _e;
3341
- const list = settings == null ? void 0 : settings.list;
3358
+ const list2 = settings == null ? void 0 : settings.list;
3342
3359
  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 };
3343
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3360
+ if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3344
3361
  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);
3345
3362
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3346
3363
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3419,7 +3436,7 @@ var klaviyo_default2 = {
3419
3436
  }]
3420
3437
  }
3421
3438
  },
3422
- relationships: { list: { data: { id: list, type: "list" } } },
3439
+ relationships: { list: { data: { id: list2, type: "list" } } },
3423
3440
  type: "profile-subscription-bulk-create-job"
3424
3441
  }
3425
3442
  },
@@ -3465,7 +3482,7 @@ var klaviyo_default2 = {
3465
3482
  skipped: true
3466
3483
  };
3467
3484
  }
3468
- const name = segmentName(segment.title);
3485
+ const name = segmentName(segment.title, segment.id);
3469
3486
  const existing = segmentRowFor({ connection: connection2, segment });
3470
3487
  let id = null;
3471
3488
  if (existing == null ? void 0 : existing.id) {
@@ -3592,7 +3609,7 @@ var klaviyo_default2 = {
3592
3609
  // The one call below proves the minted token is HONOURED — mint and
3593
3610
  // acceptance are different facts, and /accounts is already the call
3594
3611
  // the connect flow makes (auth.connect), so it needs no new scope.
3595
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3612
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3596
3613
  const request2 = { connectionId: connection2.id };
3597
3614
  try {
3598
3615
  await api2("/accounts", { fetcher, token });
@@ -3644,8 +3661,8 @@ var klaviyo_default2 = {
3644
3661
  let pages = 0;
3645
3662
  while (next && audiences.length < limit && pages < 20) {
3646
3663
  const body = await api2(next, { fetcher, token });
3647
- for (const list of (body == null ? void 0 : body.data) || []) {
3648
- audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
3664
+ for (const list2 of (body == null ? void 0 : body.data) || []) {
3665
+ audiences.push({ id: list2.id, title: ((_a = list2 == null ? void 0 : list2.attributes) == null ? void 0 : _a.name) || list2.id });
3649
3666
  }
3650
3667
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3651
3668
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3811,13 +3828,14 @@ var klaviyo_default2 = {
3811
3828
  var _a;
3812
3829
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3813
3830
  return [
3814
- // A connection made before segments were requested is authenticated and
3815
- // cannot manage them, and no error surfaces anywhere else the register
3816
- // runs skip rather than fail.
3817
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3818
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3819
- title: "Reconnect Klaviyo"
3820
- }],
3831
+ // THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
3832
+ // written by the health check, because a task is quiet: it renders only in
3833
+ // the body of this connection's own page, so a merchant who never opens it
3834
+ // never learns that their segments stopped being published. An error entry
3835
+ // reaches the card and the organization checklist too.
3836
+ //
3837
+ // Deliberately not a status change — see the health hook for why that
3838
+ // would stop the contact sync that still works.
3821
3839
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3822
3840
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3823
3841
  title: "Choose a list"
@@ -3872,7 +3890,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3872
3890
  };
3873
3891
  var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3874
3892
  var TAG_NAME_LIMIT = 100;
3875
- var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
3893
+ var tagName = (title, id) => {
3894
+ const suffix = " (" + String(id).slice(-6) + ")";
3895
+ return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
3896
+ };
3876
3897
  var mailchimp_default2 = {
3877
3898
  // OAUTH 2, authorization code. Every url below is quoted from
3878
3899
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -4053,14 +4074,17 @@ var mailchimp_default2 = {
4053
4074
  }
4054
4075
  },
4055
4076
  {
4056
- $project: { _id: 0, title: 1 }
4077
+ // THE ID AS WELL AS THE TITLE, because the tag name carries it —
4078
+ // see tagName. Without it every name here would end in the
4079
+ // string 'undefined' and match nothing register wrote.
4080
+ $project: { _id: 0, id: 1, title: 1 }
4057
4081
  }
4058
4082
  ]
4059
4083
  });
4060
- const joined = new Set(segments.map((entry) => entry.title));
4061
- const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
4062
- name: tagName(title),
4063
- status: joined.has(title) ? "active" : "inactive"
4084
+ const joined = new Set(segments.map((entry) => entry.id));
4085
+ const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
4086
+ name: tagName(entry.title, entry.id),
4087
+ status: joined.has(entry.id) ? "active" : "inactive"
4064
4088
  }));
4065
4089
  if (tags.length > 0) {
4066
4090
  await api3("/lists/" + audience + "/members/" + hash + "/tags", {
@@ -4112,7 +4136,7 @@ var mailchimp_default2 = {
4112
4136
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4113
4137
  const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4114
4138
  if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4115
- const name = tagName(segment.title);
4139
+ const name = tagName(segment.title, segment.id);
4116
4140
  const existing = segmentRowFor({ connection: connection2, segment });
4117
4141
  let id = null;
4118
4142
  if (existing == null ? void 0 : existing.id) {
@@ -4218,7 +4242,7 @@ var mailchimp_default2 = {
4218
4242
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4219
4243
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4220
4244
  );
4221
- const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
4245
+ const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
4222
4246
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4223
4247
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4224
4248
  const nextOffset = offset + count;
@@ -4665,10 +4689,6 @@ var shopify_default2 = {
4665
4689
  if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
4666
4690
  if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
4667
4691
  const adminAccessToken = await adminToken();
4668
- if (!context.shopifyCustomerId) {
4669
- const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
4670
- 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 };
4671
- }
4672
4692
  const discountCode = await shopify.admin.createDiscountCode({
4673
4693
  adminAccessToken,
4674
4694
  code: "DB-" + generateDiscountCode(),
@@ -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(),