@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.
- package/dist/connections/index.cjs +41 -24
- package/dist/connections/index.d.cts +83 -25
- package/dist/connections/index.d.ts +83 -25
- package/dist/connections/index.js +41 -24
- package/dist/providers.cjs +41 -24
- package/dist/providers.js +41 -24
- package/package.json +1 -1
|
@@ -2168,16 +2168,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
|
|
|
2168
2168
|
};
|
|
2169
2169
|
};
|
|
2170
2170
|
var summariseEvents = (events) => {
|
|
2171
|
-
const
|
|
2172
|
-
if (!
|
|
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
|
|
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 =
|
|
2180
|
-
const writes =
|
|
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: [
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
3321
|
-
scopes
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
3649
|
-
audiences.push({ 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
|
-
//
|
|
3816
|
-
//
|
|
3817
|
-
//
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
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((
|
|
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
|
-
//
|
|
3567
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
//
|
|
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).
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
//
|
|
3575
|
-
//
|
|
3576
|
-
//
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
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 :
|
|
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
|
-
//
|
|
3852
|
-
scopes
|
|
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
|
-
//
|
|
4570
|
-
//
|
|
4571
|
-
//
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
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
|
-
//
|
|
3567
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
//
|
|
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).
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
//
|
|
3575
|
-
//
|
|
3576
|
-
//
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
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 :
|
|
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
|
-
//
|
|
3852
|
-
scopes
|
|
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
|
-
//
|
|
4570
|
-
//
|
|
4571
|
-
//
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
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
|
|
2096
|
-
if (!
|
|
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
|
|
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 =
|
|
2104
|
-
const writes =
|
|
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: [
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
3245
|
-
scopes
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
3573
|
-
audiences.push({ 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
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
//
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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((
|
|
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;
|
package/dist/providers.cjs
CHANGED
|
@@ -2077,16 +2077,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
|
|
|
2077
2077
|
};
|
|
2078
2078
|
};
|
|
2079
2079
|
var summariseEvents = (events) => {
|
|
2080
|
-
const
|
|
2081
|
-
if (!
|
|
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
|
|
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 =
|
|
2089
|
-
const writes =
|
|
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: [
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
3230
|
-
scopes
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
3558
|
-
audiences.push({ 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
|
-
//
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
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((
|
|
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
|
|
2032
|
-
if (!
|
|
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
|
|
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 =
|
|
2040
|
-
const writes =
|
|
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: [
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
3181
|
-
scopes
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
3509
|
-
audiences.push({ 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
|
-
//
|
|
3676
|
-
//
|
|
3677
|
-
//
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
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((
|
|
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