@drawbridge/drawbridge-utils 0.0.172 → 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.
@@ -2168,16 +2168,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2168
2168
  };
2169
2169
  };
2170
2170
  var summariseEvents = (events) => {
2171
- const list = Array.isArray(events) ? events : [];
2172
- 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 };
2173
2173
  const counts = {};
2174
- for (const { event } of list) {
2174
+ for (const { event } of list2) {
2175
2175
  const name = event || "unknown";
2176
2176
  counts[name] = (counts[name] || 0) + 1;
2177
2177
  }
2178
2178
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2179
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2180
- 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) => {
2181
2181
  var _a;
2182
2182
  return deliveryWrite({
2183
2183
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2197,7 +2197,7 @@ var summariseEvents = (events) => {
2197
2197
  });
2198
2198
  }).filter(Boolean);
2199
2199
  return {
2200
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2200
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2201
2201
  ...writes.length ? { writes } : { skipped: true }
2202
2202
  };
2203
2203
  };
@@ -3064,6 +3064,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3064
3064
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
3065
3065
  </svg>`;
3066
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
+
3067
3075
  // lib/connections/providers/klaviyo.js
3068
3076
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
3069
3077
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -3092,7 +3100,9 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3092
3100
  return response.status === 204 ? null : response.json();
3093
3101
  };
3094
3102
  var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3095
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
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");
3096
3106
  var klaviyo_default2 = {
3097
3107
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
3098
3108
  // exchange without a code_verifier matching the challenge the consent
@@ -3136,7 +3146,7 @@ var klaviyo_default2 = {
3136
3146
  // Update and Delete Segment each list `segments:write`
3137
3147
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3138
3148
  // revision 2026-07-15, fetched 2026-09-11).
3139
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3149
+ scopes: SCOPES,
3140
3150
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3141
3151
  // the disconnect hook — three vendor addresses, two of them declared,
3142
3152
  // which is exactly the kind of split that goes unnoticed.
@@ -3317,8 +3327,14 @@ var klaviyo_default2 = {
3317
3327
  });
3318
3328
  return { ok: Boolean(token) };
3319
3329
  },
3320
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3321
- 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 }),
3322
3338
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3323
3339
  // client_id/client_secret pair as body fields. Everything else about the
3324
3340
  // request is standard, so this is the shared implementation told the one
@@ -3339,9 +3355,9 @@ var klaviyo_default2 = {
3339
3355
  remove: false,
3340
3356
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3341
3357
  var _a, _b, _c, _d, _e;
3342
- const list = settings == null ? void 0 : settings.list;
3358
+ const list2 = settings == null ? void 0 : settings.list;
3343
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 };
3344
- 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 };
3345
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);
3346
3362
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3347
3363
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3420,7 +3436,7 @@ var klaviyo_default2 = {
3420
3436
  }]
3421
3437
  }
3422
3438
  },
3423
- relationships: { list: { data: { id: list, type: "list" } } },
3439
+ relationships: { list: { data: { id: list2, type: "list" } } },
3424
3440
  type: "profile-subscription-bulk-create-job"
3425
3441
  }
3426
3442
  },
@@ -3593,7 +3609,7 @@ var klaviyo_default2 = {
3593
3609
  // The one call below proves the minted token is HONOURED — mint and
3594
3610
  // acceptance are different facts, and /accounts is already the call
3595
3611
  // the connect flow makes (auth.connect), so it needs no new scope.
3596
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3612
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3597
3613
  const request2 = { connectionId: connection2.id };
3598
3614
  try {
3599
3615
  await api2("/accounts", { fetcher, token });
@@ -3645,8 +3661,8 @@ var klaviyo_default2 = {
3645
3661
  let pages = 0;
3646
3662
  while (next && audiences.length < limit && pages < 20) {
3647
3663
  const body = await api2(next, { fetcher, token });
3648
- for (const list of (body == null ? void 0 : body.data) || []) {
3649
- 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 });
3650
3666
  }
3651
3667
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3652
3668
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3812,13 +3828,14 @@ var klaviyo_default2 = {
3812
3828
  var _a;
3813
3829
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3814
3830
  return [
3815
- // A connection made before segments were requested is authenticated and
3816
- // cannot manage them, and no error surfaces anywhere else the register
3817
- // runs skip rather than fail.
3818
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3819
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3820
- title: "Reconnect Klaviyo"
3821
- }],
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.
3822
3839
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3823
3840
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3824
3841
  title: "Choose a list"
@@ -4225,7 +4242,7 @@ var mailchimp_default2 = {
4225
4242
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4226
4243
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4227
4244
  );
4228
- 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 }));
4229
4246
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4230
4247
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4231
4248
  const nextOffset = offset + count;
@@ -3502,6 +3502,39 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3502
3502
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
3503
3503
  </svg>`;
3504
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
+
3505
3538
  const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
3506
3539
 
3507
3540
  const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
@@ -3539,7 +3572,6 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3539
3572
  // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3540
3573
  // below keys on the id — so a rename never has to touch a profile.
3541
3574
  //
3542
- // THE ID IS IN THE LABEL TOO, and that part is not cosmetic. Nothing makes a
3543
3575
  // THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
3544
3576
  // alone, a second segment called "VIP" found the first one's Klaviyo segment by
3545
3577
  // name and adopted it: both rows then pointed at one segment whose definition
@@ -3563,20 +3595,35 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3563
3595
  // guessing.
3564
3596
  const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
3565
3597
 
3566
- // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3567
- // only what the merchant consented to, so a connection made before segments were
3568
- // asked for holds one that cannot write a segment — Create, Update and Delete
3569
- // Segment each list `segments:write`
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`
3570
3614
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3571
- // 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
3572
- // nothing; this does.
3573
- //
3574
- // `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
3575
- // describes as "The scopes that this access token has access to for accessing
3576
- // API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
3577
- // 2026-09-11) not the list this manifest asks for, which is why a connection
3578
- // older than the ask reads as false rather than true.
3579
- const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
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' );
3580
3627
 
3581
3628
  // Klaviyo — contact sync, over OAuth.
3582
3629
  //
@@ -3641,7 +3688,7 @@ var klaviyo = {
3641
3688
  // Update and Delete Segment each list `segments:write`
3642
3689
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3643
3690
  // revision 2026-07-15, fetched 2026-09-11).
3644
- scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
3691
+ scopes : SCOPES,
3645
3692
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3646
3693
  // the disconnect hook — three vendor addresses, two of them declared,
3647
3694
  // which is exactly the kind of split that goes unnoticed.
@@ -3848,8 +3895,14 @@ var klaviyo = {
3848
3895
 
3849
3896
  },
3850
3897
 
3851
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3852
- 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 }),
3853
3906
 
3854
3907
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3855
3908
  // client_id/client_secret pair as body fields. Everything else about the
@@ -4275,7 +4328,7 @@ var klaviyo = {
4275
4328
  // The one call below proves the minted token is HONOURED — mint and
4276
4329
  // acceptance are different facts, and /accounts is already the call
4277
4330
  // the connect flow makes (auth.connect), so it needs no new scope.
4278
- health : async ( { connection, token }, { fetcher, read } = {} ) => {
4331
+ health : async ( { connection, settings, token }, { fetcher, read } = {} ) => {
4279
4332
 
4280
4333
  const request = { connectionId : connection.id };
4281
4334
 
@@ -4283,6 +4336,10 @@ var klaviyo = {
4283
4336
 
4284
4337
  await api$1( '/accounts', { fetcher, token });
4285
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.
4286
4343
  return {
4287
4344
  message : 'Health check passed — token minted and accepted.',
4288
4345
  request,
@@ -4566,13 +4623,14 @@ var klaviyo = {
4566
4623
  // discovers the second. The grant leads because reconnecting is the longer
4567
4624
  // errand.
4568
4625
  return [
4569
- // A connection made before segments were requested is authenticated and
4570
- // cannot manage them, and no error surfaces anywhere else the register
4571
- // runs skip rather than fail.
4572
- ...( canManageSegments( data?.settings ) ? [] : [ {
4573
- message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4574
- title : 'Reconnect Klaviyo'
4575
- } ] ),
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.
4576
4634
  ...( data?.settings?.list ? [] : [ {
4577
4635
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
4578
4636
  title : 'Choose a list'
@@ -3502,6 +3502,39 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3502
3502
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
3503
3503
  </svg>`;
3504
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
+
3505
3538
  const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
3506
3539
 
3507
3540
  const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
@@ -3539,7 +3572,6 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3539
3572
  // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3540
3573
  // below keys on the id — so a rename never has to touch a profile.
3541
3574
  //
3542
- // THE ID IS IN THE LABEL TOO, and that part is not cosmetic. Nothing makes a
3543
3575
  // THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
3544
3576
  // alone, a second segment called "VIP" found the first one's Klaviyo segment by
3545
3577
  // name and adopted it: both rows then pointed at one segment whose definition
@@ -3563,20 +3595,35 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3563
3595
  // guessing.
3564
3596
  const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
3565
3597
 
3566
- // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3567
- // only what the merchant consented to, so a connection made before segments were
3568
- // asked for holds one that cannot write a segment — Create, Update and Delete
3569
- // Segment each list `segments:write`
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`
3570
3614
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3571
- // 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
3572
- // nothing; this does.
3573
- //
3574
- // `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
3575
- // describes as "The scopes that this access token has access to for accessing
3576
- // API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
3577
- // 2026-09-11) not the list this manifest asks for, which is why a connection
3578
- // older than the ask reads as false rather than true.
3579
- const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
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' );
3580
3627
 
3581
3628
  // Klaviyo — contact sync, over OAuth.
3582
3629
  //
@@ -3641,7 +3688,7 @@ var klaviyo = {
3641
3688
  // Update and Delete Segment each list `segments:write`
3642
3689
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3643
3690
  // revision 2026-07-15, fetched 2026-09-11).
3644
- scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
3691
+ scopes : SCOPES,
3645
3692
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3646
3693
  // the disconnect hook — three vendor addresses, two of them declared,
3647
3694
  // which is exactly the kind of split that goes unnoticed.
@@ -3848,8 +3895,14 @@ var klaviyo = {
3848
3895
 
3849
3896
  },
3850
3897
 
3851
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3852
- 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 }),
3853
3906
 
3854
3907
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3855
3908
  // client_id/client_secret pair as body fields. Everything else about the
@@ -4275,7 +4328,7 @@ var klaviyo = {
4275
4328
  // The one call below proves the minted token is HONOURED — mint and
4276
4329
  // acceptance are different facts, and /accounts is already the call
4277
4330
  // the connect flow makes (auth.connect), so it needs no new scope.
4278
- health : async ( { connection, token }, { fetcher, read } = {} ) => {
4331
+ health : async ( { connection, settings, token }, { fetcher, read } = {} ) => {
4279
4332
 
4280
4333
  const request = { connectionId : connection.id };
4281
4334
 
@@ -4283,6 +4336,10 @@ var klaviyo = {
4283
4336
 
4284
4337
  await api$1( '/accounts', { fetcher, token });
4285
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.
4286
4343
  return {
4287
4344
  message : 'Health check passed — token minted and accepted.',
4288
4345
  request,
@@ -4566,13 +4623,14 @@ var klaviyo = {
4566
4623
  // discovers the second. The grant leads because reconnecting is the longer
4567
4624
  // errand.
4568
4625
  return [
4569
- // A connection made before segments were requested is authenticated and
4570
- // cannot manage them, and no error surfaces anywhere else the register
4571
- // runs skip rather than fail.
4572
- ...( canManageSegments( data?.settings ) ? [] : [ {
4573
- message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4574
- title : 'Reconnect Klaviyo'
4575
- } ] ),
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.
4576
4634
  ...( data?.settings?.list ? [] : [ {
4577
4635
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
4578
4636
  title : 'Choose a list'
@@ -2092,16 +2092,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2092
2092
  };
2093
2093
  };
2094
2094
  var summariseEvents = (events) => {
2095
- const list = Array.isArray(events) ? events : [];
2096
- 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 };
2097
2097
  const counts = {};
2098
- for (const { event } of list) {
2098
+ for (const { event } of list2) {
2099
2099
  const name = event || "unknown";
2100
2100
  counts[name] = (counts[name] || 0) + 1;
2101
2101
  }
2102
2102
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2103
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2104
- 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) => {
2105
2105
  var _a;
2106
2106
  return deliveryWrite({
2107
2107
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2121,7 +2121,7 @@ var summariseEvents = (events) => {
2121
2121
  });
2122
2122
  }).filter(Boolean);
2123
2123
  return {
2124
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2124
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2125
2125
  ...writes.length ? { writes } : { skipped: true }
2126
2126
  };
2127
2127
  };
@@ -2988,6 +2988,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
2988
2988
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
2989
2989
  </svg>`;
2990
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
+
2991
2999
  // lib/connections/providers/klaviyo.js
2992
3000
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
2993
3001
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -3016,7 +3024,9 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3016
3024
  return response.status === 204 ? null : response.json();
3017
3025
  };
3018
3026
  var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3019
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
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");
3020
3030
  var klaviyo_default2 = {
3021
3031
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
3022
3032
  // exchange without a code_verifier matching the challenge the consent
@@ -3060,7 +3070,7 @@ var klaviyo_default2 = {
3060
3070
  // Update and Delete Segment each list `segments:write`
3061
3071
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3062
3072
  // revision 2026-07-15, fetched 2026-09-11).
3063
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3073
+ scopes: SCOPES,
3064
3074
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3065
3075
  // the disconnect hook — three vendor addresses, two of them declared,
3066
3076
  // which is exactly the kind of split that goes unnoticed.
@@ -3241,8 +3251,14 @@ var klaviyo_default2 = {
3241
3251
  });
3242
3252
  return { ok: Boolean(token) };
3243
3253
  },
3244
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3245
- 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 }),
3246
3262
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3247
3263
  // client_id/client_secret pair as body fields. Everything else about the
3248
3264
  // request is standard, so this is the shared implementation told the one
@@ -3263,9 +3279,9 @@ var klaviyo_default2 = {
3263
3279
  remove: false,
3264
3280
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3265
3281
  var _a, _b, _c, _d, _e;
3266
- const list = settings == null ? void 0 : settings.list;
3282
+ const list2 = settings == null ? void 0 : settings.list;
3267
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 };
3268
- 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 };
3269
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);
3270
3286
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3271
3287
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3344,7 +3360,7 @@ var klaviyo_default2 = {
3344
3360
  }]
3345
3361
  }
3346
3362
  },
3347
- relationships: { list: { data: { id: list, type: "list" } } },
3363
+ relationships: { list: { data: { id: list2, type: "list" } } },
3348
3364
  type: "profile-subscription-bulk-create-job"
3349
3365
  }
3350
3366
  },
@@ -3517,7 +3533,7 @@ var klaviyo_default2 = {
3517
3533
  // The one call below proves the minted token is HONOURED — mint and
3518
3534
  // acceptance are different facts, and /accounts is already the call
3519
3535
  // the connect flow makes (auth.connect), so it needs no new scope.
3520
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3536
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3521
3537
  const request2 = { connectionId: connection2.id };
3522
3538
  try {
3523
3539
  await api2("/accounts", { fetcher, token });
@@ -3569,8 +3585,8 @@ var klaviyo_default2 = {
3569
3585
  let pages = 0;
3570
3586
  while (next && audiences.length < limit && pages < 20) {
3571
3587
  const body = await api2(next, { fetcher, token });
3572
- for (const list of (body == null ? void 0 : body.data) || []) {
3573
- 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 });
3574
3590
  }
3575
3591
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3576
3592
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3736,13 +3752,14 @@ var klaviyo_default2 = {
3736
3752
  var _a;
3737
3753
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3738
3754
  return [
3739
- // A connection made before segments were requested is authenticated and
3740
- // cannot manage them, and no error surfaces anywhere else the register
3741
- // runs skip rather than fail.
3742
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3743
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3744
- title: "Reconnect Klaviyo"
3745
- }],
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.
3746
3763
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3747
3764
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3748
3765
  title: "Choose a list"
@@ -4149,7 +4166,7 @@ var mailchimp_default2 = {
4149
4166
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4150
4167
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4151
4168
  );
4152
- 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 }));
4153
4170
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4154
4171
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4155
4172
  const nextOffset = offset + count;
@@ -2077,16 +2077,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2077
2077
  };
2078
2078
  };
2079
2079
  var summariseEvents = (events) => {
2080
- const list = Array.isArray(events) ? events : [];
2081
- if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2080
+ const list2 = Array.isArray(events) ? events : [];
2081
+ if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2082
2082
  const counts = {};
2083
- for (const { event } of list) {
2083
+ for (const { event } of list2) {
2084
2084
  const name = event || "unknown";
2085
2085
  counts[name] = (counts[name] || 0) + 1;
2086
2086
  }
2087
2087
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2088
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2089
- const writes = list.map((event) => {
2088
+ const refused = list2.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2089
+ const writes = list2.map((event) => {
2090
2090
  var _a;
2091
2091
  return deliveryWrite({
2092
2092
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2106,7 +2106,7 @@ var summariseEvents = (events) => {
2106
2106
  });
2107
2107
  }).filter(Boolean);
2108
2108
  return {
2109
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2109
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2110
2110
  ...writes.length ? { writes } : { skipped: true }
2111
2111
  };
2112
2112
  };
@@ -2973,6 +2973,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
2973
2973
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
2974
2974
  </svg>`;
2975
2975
 
2976
+ // lib/connections/scopes.js
2977
+ var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
2978
+ var missingScopes = ({ granted, required }) => {
2979
+ if (granted === null || granted === void 0 || granted === "") return null;
2980
+ const held = new Set(list(granted));
2981
+ return list(required).filter((scope) => !held.has(scope));
2982
+ };
2983
+
2976
2984
  // lib/connections/providers/klaviyo.js
2977
2985
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
2978
2986
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -3001,7 +3009,9 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3001
3009
  return response.status === 204 ? null : response.json();
3002
3010
  };
3003
3011
  var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3004
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
3012
+ var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
3013
+ var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
3014
+ var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
3005
3015
  var klaviyo_default2 = {
3006
3016
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
3007
3017
  // exchange without a code_verifier matching the challenge the consent
@@ -3045,7 +3055,7 @@ var klaviyo_default2 = {
3045
3055
  // Update and Delete Segment each list `segments:write`
3046
3056
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3047
3057
  // revision 2026-07-15, fetched 2026-09-11).
3048
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3058
+ scopes: SCOPES,
3049
3059
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3050
3060
  // the disconnect hook — three vendor addresses, two of them declared,
3051
3061
  // which is exactly the kind of split that goes unnoticed.
@@ -3226,8 +3236,14 @@ var klaviyo_default2 = {
3226
3236
  });
3227
3237
  return { ok: Boolean(token) };
3228
3238
  },
3229
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3230
- scopes: false,
3239
+ // THEY DO DRIFT, and the comment here used to say they could not. Klaviyo
3240
+ // scopes are fixed on the APP, so adding one re-consents every NEW grant
3241
+ // and leaves every EXISTING one exactly as narrow as it was — with no
3242
+ // error, no webhook, and nothing that notices. That is what left grants
3243
+ // authenticating perfectly while silently unable to manage a segment.
3244
+ //
3245
+ // The same slot Shopify answers, so one caller can ask any vendor.
3246
+ scopes: ({ scope }) => missingScopes({ granted: scope, required: SCOPES }),
3231
3247
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3232
3248
  // client_id/client_secret pair as body fields. Everything else about the
3233
3249
  // request is standard, so this is the shared implementation told the one
@@ -3248,9 +3264,9 @@ var klaviyo_default2 = {
3248
3264
  remove: false,
3249
3265
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3250
3266
  var _a, _b, _c, _d, _e;
3251
- const list = settings == null ? void 0 : settings.list;
3267
+ const list2 = settings == null ? void 0 : settings.list;
3252
3268
  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 };
3253
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3269
+ if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3254
3270
  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);
3255
3271
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3256
3272
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3329,7 +3345,7 @@ var klaviyo_default2 = {
3329
3345
  }]
3330
3346
  }
3331
3347
  },
3332
- relationships: { list: { data: { id: list, type: "list" } } },
3348
+ relationships: { list: { data: { id: list2, type: "list" } } },
3333
3349
  type: "profile-subscription-bulk-create-job"
3334
3350
  }
3335
3351
  },
@@ -3502,7 +3518,7 @@ var klaviyo_default2 = {
3502
3518
  // The one call below proves the minted token is HONOURED — mint and
3503
3519
  // acceptance are different facts, and /accounts is already the call
3504
3520
  // the connect flow makes (auth.connect), so it needs no new scope.
3505
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3521
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3506
3522
  const request2 = { connectionId: connection2.id };
3507
3523
  try {
3508
3524
  await api2("/accounts", { fetcher, token });
@@ -3554,8 +3570,8 @@ var klaviyo_default2 = {
3554
3570
  let pages = 0;
3555
3571
  while (next && audiences.length < limit && pages < 20) {
3556
3572
  const body = await api2(next, { fetcher, token });
3557
- for (const list of (body == null ? void 0 : body.data) || []) {
3558
- audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
3573
+ for (const list2 of (body == null ? void 0 : body.data) || []) {
3574
+ audiences.push({ id: list2.id, title: ((_a = list2 == null ? void 0 : list2.attributes) == null ? void 0 : _a.name) || list2.id });
3559
3575
  }
3560
3576
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3561
3577
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3721,13 +3737,14 @@ var klaviyo_default2 = {
3721
3737
  var _a;
3722
3738
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3723
3739
  return [
3724
- // A connection made before segments were requested is authenticated and
3725
- // cannot manage them, and no error surfaces anywhere else the register
3726
- // runs skip rather than fail.
3727
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3728
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3729
- title: "Reconnect Klaviyo"
3730
- }],
3740
+ // THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
3741
+ // written by the health check, because a task is quiet: it renders only in
3742
+ // the body of this connection's own page, so a merchant who never opens it
3743
+ // never learns that their segments stopped being published. An error entry
3744
+ // reaches the card and the organization checklist too.
3745
+ //
3746
+ // Deliberately not a status change — see the health hook for why that
3747
+ // would stop the contact sync that still works.
3731
3748
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3732
3749
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3733
3750
  title: "Choose a list"
@@ -4134,7 +4151,7 @@ var mailchimp_default2 = {
4134
4151
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4135
4152
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4136
4153
  );
4137
- const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
4154
+ const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
4138
4155
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4139
4156
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4140
4157
  const nextOffset = offset + count;
package/dist/providers.js CHANGED
@@ -2028,16 +2028,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
2028
2028
  };
2029
2029
  };
2030
2030
  var summariseEvents = (events) => {
2031
- const list = Array.isArray(events) ? events : [];
2032
- if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2031
+ const list2 = Array.isArray(events) ? events : [];
2032
+ if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
2033
2033
  const counts = {};
2034
- for (const { event } of list) {
2034
+ for (const { event } of list2) {
2035
2035
  const name = event || "unknown";
2036
2036
  counts[name] = (counts[name] || 0) + 1;
2037
2037
  }
2038
2038
  const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
2039
- const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2040
- const writes = list.map((event) => {
2039
+ const refused = list2.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
2040
+ const writes = list2.map((event) => {
2041
2041
  var _a;
2042
2042
  return deliveryWrite({
2043
2043
  at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
@@ -2057,7 +2057,7 @@ var summariseEvents = (events) => {
2057
2057
  });
2058
2058
  }).filter(Boolean);
2059
2059
  return {
2060
- message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2060
+ message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
2061
2061
  ...writes.length ? { writes } : { skipped: true }
2062
2062
  };
2063
2063
  };
@@ -2924,6 +2924,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
2924
2924
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
2925
2925
  </svg>`;
2926
2926
 
2927
+ // lib/connections/scopes.js
2928
+ var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
2929
+ var missingScopes = ({ granted, required }) => {
2930
+ if (granted === null || granted === void 0 || granted === "") return null;
2931
+ const held = new Set(list(granted));
2932
+ return list(required).filter((scope) => !held.has(scope));
2933
+ };
2934
+
2927
2935
  // lib/connections/providers/klaviyo.js
2928
2936
  var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
2929
2937
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
@@ -2952,7 +2960,9 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
2952
2960
  return response.status === 204 ? null : response.json();
2953
2961
  };
2954
2962
  var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
2955
- var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
2963
+ var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
2964
+ var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
2965
+ var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
2956
2966
  var klaviyo_default2 = {
2957
2967
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
2958
2968
  // exchange without a code_verifier matching the challenge the consent
@@ -2996,7 +3006,7 @@ var klaviyo_default2 = {
2996
3006
  // Update and Delete Segment each list `segments:write`
2997
3007
  // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
2998
3008
  // revision 2026-07-15, fetched 2026-09-11).
2999
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
3009
+ scopes: SCOPES,
3000
3010
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3001
3011
  // the disconnect hook — three vendor addresses, two of them declared,
3002
3012
  // which is exactly the kind of split that goes unnoticed.
@@ -3177,8 +3187,14 @@ var klaviyo_default2 = {
3177
3187
  });
3178
3188
  return { ok: Boolean(token) };
3179
3189
  },
3180
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
3181
- scopes: false,
3190
+ // THEY DO DRIFT, and the comment here used to say they could not. Klaviyo
3191
+ // scopes are fixed on the APP, so adding one re-consents every NEW grant
3192
+ // and leaves every EXISTING one exactly as narrow as it was — with no
3193
+ // error, no webhook, and nothing that notices. That is what left grants
3194
+ // authenticating perfectly while silently unable to manage a segment.
3195
+ //
3196
+ // The same slot Shopify answers, so one caller can ask any vendor.
3197
+ scopes: ({ scope }) => missingScopes({ granted: scope, required: SCOPES }),
3182
3198
  // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
3183
3199
  // client_id/client_secret pair as body fields. Everything else about the
3184
3200
  // request is standard, so this is the shared implementation told the one
@@ -3199,9 +3215,9 @@ var klaviyo_default2 = {
3199
3215
  remove: false,
3200
3216
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3201
3217
  var _a, _b, _c, _d, _e;
3202
- const list = settings == null ? void 0 : settings.list;
3218
+ const list2 = settings == null ? void 0 : settings.list;
3203
3219
  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 };
3204
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3220
+ if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3205
3221
  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);
3206
3222
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3207
3223
  const person = (context == null ? void 0 : context.contact) || null;
@@ -3280,7 +3296,7 @@ var klaviyo_default2 = {
3280
3296
  }]
3281
3297
  }
3282
3298
  },
3283
- relationships: { list: { data: { id: list, type: "list" } } },
3299
+ relationships: { list: { data: { id: list2, type: "list" } } },
3284
3300
  type: "profile-subscription-bulk-create-job"
3285
3301
  }
3286
3302
  },
@@ -3453,7 +3469,7 @@ var klaviyo_default2 = {
3453
3469
  // The one call below proves the minted token is HONOURED — mint and
3454
3470
  // acceptance are different facts, and /accounts is already the call
3455
3471
  // the connect flow makes (auth.connect), so it needs no new scope.
3456
- health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
3472
+ health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
3457
3473
  const request2 = { connectionId: connection2.id };
3458
3474
  try {
3459
3475
  await api2("/accounts", { fetcher, token });
@@ -3505,8 +3521,8 @@ var klaviyo_default2 = {
3505
3521
  let pages = 0;
3506
3522
  while (next && audiences.length < limit && pages < 20) {
3507
3523
  const body = await api2(next, { fetcher, token });
3508
- for (const list of (body == null ? void 0 : body.data) || []) {
3509
- audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
3524
+ for (const list2 of (body == null ? void 0 : body.data) || []) {
3525
+ audiences.push({ id: list2.id, title: ((_a = list2 == null ? void 0 : list2.attributes) == null ? void 0 : _a.name) || list2.id });
3510
3526
  }
3511
3527
  const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
3512
3528
  next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
@@ -3672,13 +3688,14 @@ var klaviyo_default2 = {
3672
3688
  var _a;
3673
3689
  if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3674
3690
  return [
3675
- // A connection made before segments were requested is authenticated and
3676
- // cannot manage them, and no error surfaces anywhere else the register
3677
- // runs skip rather than fail.
3678
- ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3679
- message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3680
- title: "Reconnect Klaviyo"
3681
- }],
3691
+ // THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
3692
+ // written by the health check, because a task is quiet: it renders only in
3693
+ // the body of this connection's own page, so a merchant who never opens it
3694
+ // never learns that their segments stopped being published. An error entry
3695
+ // reaches the card and the organization checklist too.
3696
+ //
3697
+ // Deliberately not a status change — see the health hook for why that
3698
+ // would stop the contact sync that still works.
3682
3699
  ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3683
3700
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3684
3701
  title: "Choose a list"
@@ -4085,7 +4102,7 @@ var mailchimp_default2 = {
4085
4102
  "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
4086
4103
  { dc: settings == null ? void 0 : settings.dc, fetcher, token }
4087
4104
  );
4088
- const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
4105
+ const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
4089
4106
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
4090
4107
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
4091
4108
  const nextOffset = offset + count;
package/package.json CHANGED
@@ -226,5 +226,5 @@
226
226
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
227
227
  },
228
228
  "types": "dist/index.d.ts",
229
- "version": "0.0.172"
229
+ "version": "0.0.173"
230
230
  }