@drawbridge/drawbridge-utils 0.0.171 → 0.0.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +57 -37
- package/dist/connections/index.d.cts +167 -51
- package/dist/connections/index.d.ts +167 -51
- package/dist/connections/index.js +57 -37
- package/dist/providers.cjs +57 -37
- package/dist/providers.js +57 -37
- package/package.json +2 -2
|
@@ -618,6 +618,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
|
|
|
618
618
|
};
|
|
619
619
|
};
|
|
620
620
|
var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
|
|
621
|
+
if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
|
|
621
622
|
const built = row({ connection: connection2, data: data2, manifest, row: described });
|
|
622
623
|
return [
|
|
623
624
|
// PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
|
|
@@ -2167,16 +2168,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
|
|
|
2167
2168
|
};
|
|
2168
2169
|
};
|
|
2169
2170
|
var summariseEvents = (events) => {
|
|
2170
|
-
const
|
|
2171
|
-
if (!
|
|
2171
|
+
const list2 = Array.isArray(events) ? events : [];
|
|
2172
|
+
if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
|
|
2172
2173
|
const counts = {};
|
|
2173
|
-
for (const { event } of
|
|
2174
|
+
for (const { event } of list2) {
|
|
2174
2175
|
const name = event || "unknown";
|
|
2175
2176
|
counts[name] = (counts[name] || 0) + 1;
|
|
2176
2177
|
}
|
|
2177
2178
|
const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
|
|
2178
|
-
const refused =
|
|
2179
|
-
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) => {
|
|
2180
2181
|
var _a;
|
|
2181
2182
|
return deliveryWrite({
|
|
2182
2183
|
at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
|
|
@@ -2196,7 +2197,7 @@ var summariseEvents = (events) => {
|
|
|
2196
2197
|
});
|
|
2197
2198
|
}).filter(Boolean);
|
|
2198
2199
|
return {
|
|
2199
|
-
message: [
|
|
2200
|
+
message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
|
|
2200
2201
|
...writes.length ? { writes } : { skipped: true }
|
|
2201
2202
|
};
|
|
2202
2203
|
};
|
|
@@ -3063,6 +3064,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
3063
3064
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
3064
3065
|
</svg>`;
|
|
3065
3066
|
|
|
3067
|
+
// lib/connections/scopes.js
|
|
3068
|
+
var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
|
|
3069
|
+
var missingScopes = ({ granted, required }) => {
|
|
3070
|
+
if (granted === null || granted === void 0 || granted === "") return null;
|
|
3071
|
+
const held = new Set(list(granted));
|
|
3072
|
+
return list(required).filter((scope) => !held.has(scope));
|
|
3073
|
+
};
|
|
3074
|
+
|
|
3066
3075
|
// lib/connections/providers/klaviyo.js
|
|
3067
3076
|
var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
3068
3077
|
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
@@ -3090,8 +3099,10 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
|
|
|
3090
3099
|
}
|
|
3091
3100
|
return response.status === 204 ? null : response.json();
|
|
3092
3101
|
};
|
|
3093
|
-
var segmentName = (title) => "Drawbridge: " + title;
|
|
3094
|
-
var
|
|
3102
|
+
var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
|
|
3103
|
+
var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
|
|
3104
|
+
var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
|
|
3105
|
+
var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
|
|
3095
3106
|
var klaviyo_default2 = {
|
|
3096
3107
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
3097
3108
|
// exchange without a code_verifier matching the challenge the consent
|
|
@@ -3135,7 +3146,7 @@ var klaviyo_default2 = {
|
|
|
3135
3146
|
// Update and Delete Segment each list `segments:write`
|
|
3136
3147
|
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3137
3148
|
// revision 2026-07-15, fetched 2026-09-11).
|
|
3138
|
-
scopes:
|
|
3149
|
+
scopes: SCOPES,
|
|
3139
3150
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
3140
3151
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
3141
3152
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -3316,8 +3327,14 @@ var klaviyo_default2 = {
|
|
|
3316
3327
|
});
|
|
3317
3328
|
return { ok: Boolean(token) };
|
|
3318
3329
|
},
|
|
3319
|
-
//
|
|
3320
|
-
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 }),
|
|
3321
3338
|
// KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
|
|
3322
3339
|
// client_id/client_secret pair as body fields. Everything else about the
|
|
3323
3340
|
// request is standard, so this is the shared implementation told the one
|
|
@@ -3338,9 +3355,9 @@ var klaviyo_default2 = {
|
|
|
3338
3355
|
remove: false,
|
|
3339
3356
|
sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
|
|
3340
3357
|
var _a, _b, _c, _d, _e;
|
|
3341
|
-
const
|
|
3358
|
+
const list2 = settings == null ? void 0 : settings.list;
|
|
3342
3359
|
const request2 = { email: ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email) || null, list: (settings == null ? void 0 : settings.list) || null };
|
|
3343
|
-
if (!
|
|
3360
|
+
if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
|
|
3344
3361
|
const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
|
|
3345
3362
|
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
3346
3363
|
const person = (context == null ? void 0 : context.contact) || null;
|
|
@@ -3419,7 +3436,7 @@ var klaviyo_default2 = {
|
|
|
3419
3436
|
}]
|
|
3420
3437
|
}
|
|
3421
3438
|
},
|
|
3422
|
-
relationships: { list: { data: { id:
|
|
3439
|
+
relationships: { list: { data: { id: list2, type: "list" } } },
|
|
3423
3440
|
type: "profile-subscription-bulk-create-job"
|
|
3424
3441
|
}
|
|
3425
3442
|
},
|
|
@@ -3465,7 +3482,7 @@ var klaviyo_default2 = {
|
|
|
3465
3482
|
skipped: true
|
|
3466
3483
|
};
|
|
3467
3484
|
}
|
|
3468
|
-
const name = segmentName(segment.title);
|
|
3485
|
+
const name = segmentName(segment.title, segment.id);
|
|
3469
3486
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
3470
3487
|
let id = null;
|
|
3471
3488
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -3592,7 +3609,7 @@ var klaviyo_default2 = {
|
|
|
3592
3609
|
// The one call below proves the minted token is HONOURED — mint and
|
|
3593
3610
|
// acceptance are different facts, and /accounts is already the call
|
|
3594
3611
|
// the connect flow makes (auth.connect), so it needs no new scope.
|
|
3595
|
-
health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
|
|
3612
|
+
health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
|
|
3596
3613
|
const request2 = { connectionId: connection2.id };
|
|
3597
3614
|
try {
|
|
3598
3615
|
await api2("/accounts", { fetcher, token });
|
|
@@ -3644,8 +3661,8 @@ var klaviyo_default2 = {
|
|
|
3644
3661
|
let pages = 0;
|
|
3645
3662
|
while (next && audiences.length < limit && pages < 20) {
|
|
3646
3663
|
const body = await api2(next, { fetcher, token });
|
|
3647
|
-
for (const
|
|
3648
|
-
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 });
|
|
3649
3666
|
}
|
|
3650
3667
|
const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
|
|
3651
3668
|
next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
|
|
@@ -3811,13 +3828,14 @@ var klaviyo_default2 = {
|
|
|
3811
3828
|
var _a;
|
|
3812
3829
|
if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
|
|
3813
3830
|
return [
|
|
3814
|
-
//
|
|
3815
|
-
//
|
|
3816
|
-
//
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3831
|
+
// THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
|
|
3832
|
+
// written by the health check, because a task is quiet: it renders only in
|
|
3833
|
+
// the body of this connection's own page, so a merchant who never opens it
|
|
3834
|
+
// never learns that their segments stopped being published. An error entry
|
|
3835
|
+
// reaches the card and the organization checklist too.
|
|
3836
|
+
//
|
|
3837
|
+
// Deliberately not a status change — see the health hook for why that
|
|
3838
|
+
// would stop the contact sync that still works.
|
|
3821
3839
|
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
|
|
3822
3840
|
message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
|
|
3823
3841
|
title: "Choose a list"
|
|
@@ -3872,7 +3890,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
3872
3890
|
};
|
|
3873
3891
|
var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
3874
3892
|
var TAG_NAME_LIMIT = 100;
|
|
3875
|
-
var tagName = (title) =>
|
|
3893
|
+
var tagName = (title, id) => {
|
|
3894
|
+
const suffix = " (" + String(id).slice(-6) + ")";
|
|
3895
|
+
return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
|
|
3896
|
+
};
|
|
3876
3897
|
var mailchimp_default2 = {
|
|
3877
3898
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
3878
3899
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -4053,14 +4074,17 @@ var mailchimp_default2 = {
|
|
|
4053
4074
|
}
|
|
4054
4075
|
},
|
|
4055
4076
|
{
|
|
4056
|
-
|
|
4077
|
+
// THE ID AS WELL AS THE TITLE, because the tag name carries it —
|
|
4078
|
+
// see tagName. Without it every name here would end in the
|
|
4079
|
+
// string 'undefined' and match nothing register wrote.
|
|
4080
|
+
$project: { _id: 0, id: 1, title: 1 }
|
|
4057
4081
|
}
|
|
4058
4082
|
]
|
|
4059
4083
|
});
|
|
4060
|
-
const joined = new Set(segments.map((entry) => entry.
|
|
4061
|
-
const tags = (owned || []).
|
|
4062
|
-
name: tagName(title),
|
|
4063
|
-
status: joined.has(
|
|
4084
|
+
const joined = new Set(segments.map((entry) => entry.id));
|
|
4085
|
+
const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
|
|
4086
|
+
name: tagName(entry.title, entry.id),
|
|
4087
|
+
status: joined.has(entry.id) ? "active" : "inactive"
|
|
4064
4088
|
}));
|
|
4065
4089
|
if (tags.length > 0) {
|
|
4066
4090
|
await api3("/lists/" + audience + "/members/" + hash + "/tags", {
|
|
@@ -4112,7 +4136,7 @@ var mailchimp_default2 = {
|
|
|
4112
4136
|
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
4113
4137
|
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
4114
4138
|
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
4115
|
-
const name = tagName(segment.title);
|
|
4139
|
+
const name = tagName(segment.title, segment.id);
|
|
4116
4140
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
4117
4141
|
let id = null;
|
|
4118
4142
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -4218,7 +4242,7 @@ var mailchimp_default2 = {
|
|
|
4218
4242
|
"/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
|
|
4219
4243
|
{ dc: settings == null ? void 0 : settings.dc, fetcher, token }
|
|
4220
4244
|
);
|
|
4221
|
-
const audiences = ((body == null ? void 0 : body.lists) || []).map((
|
|
4245
|
+
const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
|
|
4222
4246
|
const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
|
|
4223
4247
|
const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
|
|
4224
4248
|
const nextOffset = offset + count;
|
|
@@ -4665,10 +4689,6 @@ var shopify_default2 = {
|
|
|
4665
4689
|
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
4666
4690
|
if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
4667
4691
|
const adminAccessToken = await adminToken();
|
|
4668
|
-
if (!context.shopifyCustomerId) {
|
|
4669
|
-
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
|
|
4670
|
-
if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
4671
|
-
}
|
|
4672
4692
|
const discountCode = await shopify.admin.createDiscountCode({
|
|
4673
4693
|
adminAccessToken,
|
|
4674
4694
|
code: "DB-" + generateDiscountCode(),
|
|
@@ -854,6 +854,13 @@ const row = ({ connection, data, manifest, row : described }) => ({
|
|
|
854
854
|
|
|
855
855
|
const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
|
|
856
856
|
|
|
857
|
+
// NO ID, NO ROW. `String( undefined )` is the string 'undefined', which is a
|
|
858
|
+
// perfectly valid string as far as the api's schema is concerned — so a row
|
|
859
|
+
// built without a vendor id would store and then render as a live-looking
|
|
860
|
+
// link to an object that does not exist. The three hooks all guard before
|
|
861
|
+
// they get here; this is the guard that does not depend on them remembering.
|
|
862
|
+
if( described?.id === undefined || described?.id === null ) return [];
|
|
863
|
+
|
|
857
864
|
const built = row({ connection, data, manifest, row : described });
|
|
858
865
|
|
|
859
866
|
return [
|
|
@@ -3495,6 +3502,39 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
3495
3502
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
3496
3503
|
</svg>`;
|
|
3497
3504
|
|
|
3505
|
+
// WHAT A GRANT IS MISSING, as set arithmetic and nothing else.
|
|
3506
|
+
//
|
|
3507
|
+
// Every vendor that can drift answers `auth.scopes` with what is absent from the
|
|
3508
|
+
// grant it was handed. The comparison itself is the same everywhere — which
|
|
3509
|
+
// strings did we ask for, which strings came back — so it lives here rather than
|
|
3510
|
+
// being retyped per manifest, where each copy is a chance to split on the wrong
|
|
3511
|
+
// character or forget to filter the empty string.
|
|
3512
|
+
//
|
|
3513
|
+
// SPACE SEPARATED IN, ARRAY OUT. OAuth scope strings are space separated by
|
|
3514
|
+
// RFC 6749 §3.3, and both halves are tolerated as arrays because a vendor SDK may
|
|
3515
|
+
// already have parsed one.
|
|
3516
|
+
//
|
|
3517
|
+
// AN EMPTY ANSWER IS NOT THE SAME AS AN UNKNOWN ONE. A caller with no grant
|
|
3518
|
+
// string has learned nothing and must not read "nothing missing" from that — so
|
|
3519
|
+
// an absent `granted` answers null, and only a grant we actually read answers a
|
|
3520
|
+
// list. Getting this wrong reports every connection healthy the moment a read
|
|
3521
|
+
// fails, which is the silent direction.
|
|
3522
|
+
const list = ( value ) => (
|
|
3523
|
+
Array.isArray( value )
|
|
3524
|
+
? value.flatMap( ( entry ) => String( entry ).split( /\s+/ ) )
|
|
3525
|
+
: String( value ?? '' ).split( /\s+/ )
|
|
3526
|
+
).map( ( entry ) => entry.trim() ).filter( Boolean );
|
|
3527
|
+
|
|
3528
|
+
const missingScopes = ({ granted, required }) => {
|
|
3529
|
+
|
|
3530
|
+
if( granted === null || granted === undefined || granted === '' ) return null;
|
|
3531
|
+
|
|
3532
|
+
const held = new Set( list( granted ) );
|
|
3533
|
+
|
|
3534
|
+
return list( required ).filter( ( scope ) => ! held.has( scope ) );
|
|
3535
|
+
|
|
3536
|
+
};
|
|
3537
|
+
|
|
3498
3538
|
const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
3499
3539
|
|
|
3500
3540
|
const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
|
|
@@ -3531,22 +3571,59 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
|
|
|
3531
3571
|
|
|
3532
3572
|
// The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
|
|
3533
3573
|
// below keys on the id — so a rename never has to touch a profile.
|
|
3534
|
-
const segmentName = ( title ) => 'Drawbridge: ' + title;
|
|
3535
|
-
|
|
3536
|
-
// THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
|
|
3537
|
-
// only what the merchant consented to, so a connection made before segments were
|
|
3538
|
-
// asked for holds one that cannot write a segment — Create, Update and Delete
|
|
3539
|
-
// Segment each list `segments:write`
|
|
3540
|
-
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
|
|
3541
|
-
// 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
|
|
3542
|
-
// nothing; this does.
|
|
3543
3574
|
//
|
|
3544
|
-
//
|
|
3545
|
-
//
|
|
3546
|
-
//
|
|
3547
|
-
//
|
|
3548
|
-
//
|
|
3549
|
-
|
|
3575
|
+
// THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
|
|
3576
|
+
// alone, a second segment called "VIP" found the first one's Klaviyo segment by
|
|
3577
|
+
// name and adopted it: both rows then pointed at one segment whose definition
|
|
3578
|
+
// computes the FIRST one's membership, so the merchant sent to the wrong people,
|
|
3579
|
+
// and deleting either segment deleted that object out from under the other with
|
|
3580
|
+
// nothing to repair it.
|
|
3581
|
+
//
|
|
3582
|
+
// The api now carries a unique index on (organization, title) and a 409 on both
|
|
3583
|
+
// create and rename, so that pair can no longer be created. The suffix stays
|
|
3584
|
+
// anyway: it is what makes the damage impossible rather than merely unlikely,
|
|
3585
|
+
// and it is the only part of this that holds if a title ever reaches Klaviyo
|
|
3586
|
+
// from somewhere that did not go through the route.
|
|
3587
|
+
//
|
|
3588
|
+
// KLAVIYO IS NOT DOING THIS FOR US. Create Segment describes `name` only as "A
|
|
3589
|
+
// helpful name to label the segment" and documents no uniqueness constraint and
|
|
3590
|
+
// no duplicate-name error (developers.klaviyo.com/en/reference/create_segment,
|
|
3591
|
+
// fetched 2026-09-12), so the search cannot lean on the vendor. What IS
|
|
3592
|
+
// documented is that filter comparisons are case-sensitive
|
|
3593
|
+
// (developers.klaviyo.com/en/docs/filtering_, same date), which is why the
|
|
3594
|
+
// find-by-name below re-checks with === and agrees with the vendor rather than
|
|
3595
|
+
// guessing.
|
|
3596
|
+
const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
|
|
3597
|
+
|
|
3598
|
+
// `scope` on the stored settings is the grant Klaviyo RETURNED on the exchange,
|
|
3599
|
+
// which its OAuth guide describes as "The scopes that this access token has
|
|
3600
|
+
// access to for accessing API resources"
|
|
3601
|
+
// (developers.klaviyo.com/en/docs/set_up_oauth, fetched 2026-09-11) — not the
|
|
3602
|
+
// list this manifest asks for. That difference is the whole drift: a connection
|
|
3603
|
+
// older than the ask holds a narrower grant and nothing says so.
|
|
3604
|
+
//
|
|
3605
|
+
// THE SCOPES THIS APP ASKS FOR, named once. The consent url and the drift check
|
|
3606
|
+
// read the same constant, so a scope added to one is never missing from the
|
|
3607
|
+
// other — which is exactly how a grant ends up unable to do something nobody
|
|
3608
|
+
// noticed it could no longer do.
|
|
3609
|
+
//
|
|
3610
|
+
// Space separated. accounts:read is required by Klaviyo on every app; the rest
|
|
3611
|
+
// are what a contact sync and the segment hooks need — Get Segments lists
|
|
3612
|
+
// `segments:read`, and Create, Update and Delete Segment each list
|
|
3613
|
+
// `segments:write`
|
|
3614
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
|
|
3615
|
+
// 2026-07-15, fetched 2026-09-11).
|
|
3616
|
+
const SCOPES = 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write';
|
|
3617
|
+
|
|
3618
|
+
// WHAT THIS GRANT CANNOT DO, through the shared comparison every vendor uses.
|
|
3619
|
+
// Answers null when there is no grant string to judge, which is not the same as
|
|
3620
|
+
// "nothing missing" — see lib/connections/scopes.js.
|
|
3621
|
+
const missing = ( settings ) => missingScopes({ granted : settings?.scope, required : SCOPES });
|
|
3622
|
+
|
|
3623
|
+
// The register and remove hooks hard-skip on this rather than calling Klaviyo
|
|
3624
|
+
// and reading a 403 back. It asks the same question the drift check does, of the
|
|
3625
|
+
// same constant.
|
|
3626
|
+
const canManageSegments = ( settings ) => ! ( missing( settings ) || [] ).includes( 'segments:write' );
|
|
3550
3627
|
|
|
3551
3628
|
// Klaviyo — contact sync, over OAuth.
|
|
3552
3629
|
//
|
|
@@ -3611,7 +3688,7 @@ var klaviyo = {
|
|
|
3611
3688
|
// Update and Delete Segment each list `segments:write`
|
|
3612
3689
|
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3613
3690
|
// revision 2026-07-15, fetched 2026-09-11).
|
|
3614
|
-
scopes :
|
|
3691
|
+
scopes : SCOPES,
|
|
3615
3692
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
3616
3693
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
3617
3694
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -3818,8 +3895,14 @@ var klaviyo = {
|
|
|
3818
3895
|
|
|
3819
3896
|
},
|
|
3820
3897
|
|
|
3821
|
-
//
|
|
3822
|
-
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 }),
|
|
3823
3906
|
|
|
3824
3907
|
// KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
|
|
3825
3908
|
// client_id/client_secret pair as body fields. Everything else about the
|
|
@@ -4047,7 +4130,7 @@ var klaviyo = {
|
|
|
4047
4130
|
|
|
4048
4131
|
}
|
|
4049
4132
|
|
|
4050
|
-
const name = segmentName( segment.title );
|
|
4133
|
+
const name = segmentName( segment.title, segment.id );
|
|
4051
4134
|
const existing = segmentRowFor({ connection, segment });
|
|
4052
4135
|
|
|
4053
4136
|
let id = null;
|
|
@@ -4245,7 +4328,7 @@ var klaviyo = {
|
|
|
4245
4328
|
// The one call below proves the minted token is HONOURED — mint and
|
|
4246
4329
|
// acceptance are different facts, and /accounts is already the call
|
|
4247
4330
|
// the connect flow makes (auth.connect), so it needs no new scope.
|
|
4248
|
-
health : async ( { connection, token }, { fetcher, read } = {} ) => {
|
|
4331
|
+
health : async ( { connection, settings, token }, { fetcher, read } = {} ) => {
|
|
4249
4332
|
|
|
4250
4333
|
const request = { connectionId : connection.id };
|
|
4251
4334
|
|
|
@@ -4253,6 +4336,10 @@ var klaviyo = {
|
|
|
4253
4336
|
|
|
4254
4337
|
await api$1( '/accounts', { fetcher, token });
|
|
4255
4338
|
|
|
4339
|
+
// SCOPE DRIFT IS NOT CHECKED HERE. The shell does it for every vendor
|
|
4340
|
+
// after this step succeeds, by asking hooks.auth.scopes — see
|
|
4341
|
+
// reconcileScopes in drawbridge-sync lib/step-runner.js. This hook
|
|
4342
|
+
// answers only whether the token was minted and honoured.
|
|
4256
4343
|
return {
|
|
4257
4344
|
message : 'Health check passed — token minted and accepted.',
|
|
4258
4345
|
request,
|
|
@@ -4536,13 +4623,14 @@ var klaviyo = {
|
|
|
4536
4623
|
// discovers the second. The grant leads because reconnecting is the longer
|
|
4537
4624
|
// errand.
|
|
4538
4625
|
return [
|
|
4539
|
-
//
|
|
4540
|
-
//
|
|
4541
|
-
//
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4626
|
+
// THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
|
|
4627
|
+
// written by the health check, because a task is quiet: it renders only in
|
|
4628
|
+
// the body of this connection's own page, so a merchant who never opens it
|
|
4629
|
+
// never learns that their segments stopped being published. An error entry
|
|
4630
|
+
// reaches the card and the organization checklist too.
|
|
4631
|
+
//
|
|
4632
|
+
// Deliberately not a status change — see the health hook for why that
|
|
4633
|
+
// would stop the contact sync that still works.
|
|
4546
4634
|
...( data?.settings?.list ? [] : [ {
|
|
4547
4635
|
message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
|
|
4548
4636
|
title : 'Choose a list'
|
|
@@ -4657,13 +4745,29 @@ const subscriberHash = ( email ) => createHash( 'md5' )
|
|
|
4657
4745
|
// server-side truncation is the worse outcome because register would then look
|
|
4658
4746
|
// for a name Mailchimp had silently changed.
|
|
4659
4747
|
//
|
|
4660
|
-
//
|
|
4661
|
-
//
|
|
4662
|
-
//
|
|
4663
|
-
//
|
|
4748
|
+
// THE SEGMENT ID IS IN THE NAME, and it has to be. Nothing makes a Drawbridge
|
|
4749
|
+
// segment title unique — no index on the collection, no check in the route, no
|
|
4750
|
+
// check in the form — so two segments called "VIP" are ordinary. Keyed on title
|
|
4751
|
+
// alone, the second one's register finds the first one's tag, adopts it, and
|
|
4752
|
+
// both rows point at one object: the merchant's two segments are one tag, and
|
|
4753
|
+
// deleting either segment deletes the tag out from under the other, leaving a
|
|
4754
|
+
// row pointing at an id that no longer exists and nothing to repair it.
|
|
4755
|
+
//
|
|
4756
|
+
// The last six characters of the id are enough to separate them and short
|
|
4757
|
+
// enough to leave the title readable in the merchant's own audience.
|
|
4758
|
+
//
|
|
4759
|
+
// A rename now changes the name and keeps the suffix, so register's find-by-name
|
|
4760
|
+
// still matches after one — and the truncation below cuts the TITLE rather than
|
|
4761
|
+
// the suffix for the same reason.
|
|
4664
4762
|
const TAG_NAME_LIMIT = 100;
|
|
4665
4763
|
|
|
4666
|
-
const tagName = ( title ) =>
|
|
4764
|
+
const tagName = ( title, id ) => {
|
|
4765
|
+
|
|
4766
|
+
const suffix = ' (' + String( id ).slice( -6 ) + ')';
|
|
4767
|
+
|
|
4768
|
+
return ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT - suffix.length ) + suffix;
|
|
4769
|
+
|
|
4770
|
+
};
|
|
4667
4771
|
|
|
4668
4772
|
// Mailchimp — contact sync, not a sender.
|
|
4669
4773
|
var mailchimp = {
|
|
@@ -4958,19 +5062,21 @@ var mailchimp = {
|
|
|
4958
5062
|
}
|
|
4959
5063
|
},
|
|
4960
5064
|
{
|
|
4961
|
-
|
|
5065
|
+
// THE ID AS WELL AS THE TITLE, because the tag name carries it —
|
|
5066
|
+
// see tagName. Without it every name here would end in the
|
|
5067
|
+
// string 'undefined' and match nothing register wrote.
|
|
5068
|
+
$project : { _id : 0, id : 1, title : 1 }
|
|
4962
5069
|
}
|
|
4963
5070
|
]
|
|
4964
5071
|
});
|
|
4965
5072
|
|
|
4966
|
-
const joined = new Set( segments.map( ( entry ) => entry.
|
|
5073
|
+
const joined = new Set( segments.map( ( entry ) => entry.id ) );
|
|
4967
5074
|
|
|
4968
5075
|
const tags = ( owned || [] )
|
|
4969
|
-
.
|
|
4970
|
-
.
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
status : joined.has( title ) ? 'active' : 'inactive'
|
|
5076
|
+
.filter( ( entry ) => entry.id && entry.title )
|
|
5077
|
+
.map( ( entry ) => ({
|
|
5078
|
+
name : tagName( entry.title, entry.id ),
|
|
5079
|
+
status : joined.has( entry.id ) ? 'active' : 'inactive'
|
|
4974
5080
|
}) );
|
|
4975
5081
|
|
|
4976
5082
|
if( tags.length > 0 ){
|
|
@@ -5040,7 +5146,7 @@ var mailchimp = {
|
|
|
5040
5146
|
|
|
5041
5147
|
if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
|
|
5042
5148
|
|
|
5043
|
-
const name = tagName( segment.title );
|
|
5149
|
+
const name = tagName( segment.title, segment.id );
|
|
5044
5150
|
const existing = segmentRowFor({ connection, segment });
|
|
5045
5151
|
|
|
5046
5152
|
let id = null;
|
|
@@ -5768,23 +5874,33 @@ var shopify = {
|
|
|
5768
5874
|
|
|
5769
5875
|
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
5770
5876
|
|
|
5877
|
+
// KEPT even though nothing below sends the address anywhere. A code
|
|
5878
|
+
// that reaches nobody is a wasted action and a merchant's wasted
|
|
5879
|
+
// allowance: issuing is only half a workflow, and the email step after
|
|
5880
|
+
// it needs somewhere to send the code.
|
|
5771
5881
|
if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
|
|
5772
5882
|
if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
|
|
5773
5883
|
if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
|
|
5774
5884
|
|
|
5775
5885
|
const adminAccessToken = await adminToken();
|
|
5776
5886
|
|
|
5777
|
-
//
|
|
5778
|
-
//
|
|
5779
|
-
//
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5887
|
+
// NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
|
|
5888
|
+
// there is nothing to send: discountRedeemCodeBulkAdd takes a discount
|
|
5889
|
+
// and a list of codes, and no customer
|
|
5890
|
+
// (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
|
|
5891
|
+
// The link lives entirely on our side — `lead.shopifyDiscountCode`
|
|
5892
|
+
// below — and attribution happens when the order comes back carrying
|
|
5893
|
+
// the code, matched org-scoped against that field by commerce.order.
|
|
5894
|
+
//
|
|
5895
|
+
// This step used to call getOrCreateCustomer first, on the stated
|
|
5896
|
+
// reasoning that "the customer must exist before a code is mapped to
|
|
5897
|
+
// them". No code was ever mapped to them: the result was assigned and
|
|
5898
|
+
// discarded, createDiscountCode never received it, and redemption
|
|
5899
|
+
// attribution never consulted it. All it did was create a customer in
|
|
5900
|
+
// the merchant's store as a side effect of issuing a discount — which
|
|
5901
|
+
// QA reported as surprising, and was right to. A merchant who wants the
|
|
5902
|
+
// buyer to exist at the store adds the Create customer step, which is
|
|
5903
|
+
// what that step is for.
|
|
5788
5904
|
const discountCode = await shopify.admin.createDiscountCode({
|
|
5789
5905
|
adminAccessToken,
|
|
5790
5906
|
code : 'DB-' + generateDiscountCode(),
|