@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
|
@@ -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(),
|
|
@@ -542,6 +542,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
|
|
|
542
542
|
};
|
|
543
543
|
};
|
|
544
544
|
var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
|
|
545
|
+
if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
|
|
545
546
|
const built = row({ connection: connection2, data: data2, manifest, row: described });
|
|
546
547
|
return [
|
|
547
548
|
// PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
|
|
@@ -2091,16 +2092,16 @@ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reaso
|
|
|
2091
2092
|
};
|
|
2092
2093
|
};
|
|
2093
2094
|
var summariseEvents = (events) => {
|
|
2094
|
-
const
|
|
2095
|
-
if (!
|
|
2095
|
+
const list2 = Array.isArray(events) ? events : [];
|
|
2096
|
+
if (!list2.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
|
|
2096
2097
|
const counts = {};
|
|
2097
|
-
for (const { event } of
|
|
2098
|
+
for (const { event } of list2) {
|
|
2098
2099
|
const name = event || "unknown";
|
|
2099
2100
|
counts[name] = (counts[name] || 0) + 1;
|
|
2100
2101
|
}
|
|
2101
2102
|
const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
|
|
2102
|
-
const refused =
|
|
2103
|
-
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) => {
|
|
2104
2105
|
var _a;
|
|
2105
2106
|
return deliveryWrite({
|
|
2106
2107
|
at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
|
|
@@ -2120,7 +2121,7 @@ var summariseEvents = (events) => {
|
|
|
2120
2121
|
});
|
|
2121
2122
|
}).filter(Boolean);
|
|
2122
2123
|
return {
|
|
2123
|
-
message: [
|
|
2124
|
+
message: [list2.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
|
|
2124
2125
|
...writes.length ? { writes } : { skipped: true }
|
|
2125
2126
|
};
|
|
2126
2127
|
};
|
|
@@ -2987,6 +2988,14 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
2987
2988
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
2988
2989
|
</svg>`;
|
|
2989
2990
|
|
|
2991
|
+
// lib/connections/scopes.js
|
|
2992
|
+
var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
|
|
2993
|
+
var missingScopes = ({ granted, required }) => {
|
|
2994
|
+
if (granted === null || granted === void 0 || granted === "") return null;
|
|
2995
|
+
const held = new Set(list(granted));
|
|
2996
|
+
return list(required).filter((scope) => !held.has(scope));
|
|
2997
|
+
};
|
|
2998
|
+
|
|
2990
2999
|
// lib/connections/providers/klaviyo.js
|
|
2991
3000
|
var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
2992
3001
|
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
@@ -3014,8 +3023,10 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
|
|
|
3014
3023
|
}
|
|
3015
3024
|
return response.status === 204 ? null : response.json();
|
|
3016
3025
|
};
|
|
3017
|
-
var segmentName = (title) => "Drawbridge: " + title;
|
|
3018
|
-
var
|
|
3026
|
+
var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
|
|
3027
|
+
var SCOPES = "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write";
|
|
3028
|
+
var missing = (settings) => missingScopes({ granted: settings == null ? void 0 : settings.scope, required: SCOPES });
|
|
3029
|
+
var canManageSegments = (settings) => !(missing(settings) || []).includes("segments:write");
|
|
3019
3030
|
var klaviyo_default2 = {
|
|
3020
3031
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
3021
3032
|
// exchange without a code_verifier matching the challenge the consent
|
|
@@ -3059,7 +3070,7 @@ var klaviyo_default2 = {
|
|
|
3059
3070
|
// Update and Delete Segment each list `segments:write`
|
|
3060
3071
|
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3061
3072
|
// revision 2026-07-15, fetched 2026-09-11).
|
|
3062
|
-
scopes:
|
|
3073
|
+
scopes: SCOPES,
|
|
3063
3074
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
3064
3075
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
3065
3076
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -3240,8 +3251,14 @@ var klaviyo_default2 = {
|
|
|
3240
3251
|
});
|
|
3241
3252
|
return { ok: Boolean(token) };
|
|
3242
3253
|
},
|
|
3243
|
-
//
|
|
3244
|
-
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 }),
|
|
3245
3262
|
// KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
|
|
3246
3263
|
// client_id/client_secret pair as body fields. Everything else about the
|
|
3247
3264
|
// request is standard, so this is the shared implementation told the one
|
|
@@ -3262,9 +3279,9 @@ var klaviyo_default2 = {
|
|
|
3262
3279
|
remove: false,
|
|
3263
3280
|
sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
|
|
3264
3281
|
var _a, _b, _c, _d, _e;
|
|
3265
|
-
const
|
|
3282
|
+
const list2 = settings == null ? void 0 : settings.list;
|
|
3266
3283
|
const request2 = { email: ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email) || null, list: (settings == null ? void 0 : settings.list) || null };
|
|
3267
|
-
if (!
|
|
3284
|
+
if (!list2) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
|
|
3268
3285
|
const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
|
|
3269
3286
|
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
3270
3287
|
const person = (context == null ? void 0 : context.contact) || null;
|
|
@@ -3343,7 +3360,7 @@ var klaviyo_default2 = {
|
|
|
3343
3360
|
}]
|
|
3344
3361
|
}
|
|
3345
3362
|
},
|
|
3346
|
-
relationships: { list: { data: { id:
|
|
3363
|
+
relationships: { list: { data: { id: list2, type: "list" } } },
|
|
3347
3364
|
type: "profile-subscription-bulk-create-job"
|
|
3348
3365
|
}
|
|
3349
3366
|
},
|
|
@@ -3389,7 +3406,7 @@ var klaviyo_default2 = {
|
|
|
3389
3406
|
skipped: true
|
|
3390
3407
|
};
|
|
3391
3408
|
}
|
|
3392
|
-
const name = segmentName(segment.title);
|
|
3409
|
+
const name = segmentName(segment.title, segment.id);
|
|
3393
3410
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
3394
3411
|
let id = null;
|
|
3395
3412
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -3516,7 +3533,7 @@ var klaviyo_default2 = {
|
|
|
3516
3533
|
// The one call below proves the minted token is HONOURED — mint and
|
|
3517
3534
|
// acceptance are different facts, and /accounts is already the call
|
|
3518
3535
|
// the connect flow makes (auth.connect), so it needs no new scope.
|
|
3519
|
-
health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
|
|
3536
|
+
health: async ({ connection: connection2, settings, token }, { fetcher, read } = {}) => {
|
|
3520
3537
|
const request2 = { connectionId: connection2.id };
|
|
3521
3538
|
try {
|
|
3522
3539
|
await api2("/accounts", { fetcher, token });
|
|
@@ -3568,8 +3585,8 @@ var klaviyo_default2 = {
|
|
|
3568
3585
|
let pages = 0;
|
|
3569
3586
|
while (next && audiences.length < limit && pages < 20) {
|
|
3570
3587
|
const body = await api2(next, { fetcher, token });
|
|
3571
|
-
for (const
|
|
3572
|
-
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 });
|
|
3573
3590
|
}
|
|
3574
3591
|
const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
|
|
3575
3592
|
next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
|
|
@@ -3735,13 +3752,14 @@ var klaviyo_default2 = {
|
|
|
3735
3752
|
var _a;
|
|
3736
3753
|
if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
|
|
3737
3754
|
return [
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3755
|
+
// THE MISSING SEGMENT SCOPE IS NOT HERE ANY MORE. It is an ERROR entry,
|
|
3756
|
+
// written by the health check, because a task is quiet: it renders only in
|
|
3757
|
+
// the body of this connection's own page, so a merchant who never opens it
|
|
3758
|
+
// never learns that their segments stopped being published. An error entry
|
|
3759
|
+
// reaches the card and the organization checklist too.
|
|
3760
|
+
//
|
|
3761
|
+
// Deliberately not a status change — see the health hook for why that
|
|
3762
|
+
// would stop the contact sync that still works.
|
|
3745
3763
|
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
|
|
3746
3764
|
message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
|
|
3747
3765
|
title: "Choose a list"
|
|
@@ -3796,7 +3814,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
3796
3814
|
};
|
|
3797
3815
|
var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
3798
3816
|
var TAG_NAME_LIMIT = 100;
|
|
3799
|
-
var tagName = (title) =>
|
|
3817
|
+
var tagName = (title, id) => {
|
|
3818
|
+
const suffix = " (" + String(id).slice(-6) + ")";
|
|
3819
|
+
return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
|
|
3820
|
+
};
|
|
3800
3821
|
var mailchimp_default2 = {
|
|
3801
3822
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
3802
3823
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -3977,14 +3998,17 @@ var mailchimp_default2 = {
|
|
|
3977
3998
|
}
|
|
3978
3999
|
},
|
|
3979
4000
|
{
|
|
3980
|
-
|
|
4001
|
+
// THE ID AS WELL AS THE TITLE, because the tag name carries it —
|
|
4002
|
+
// see tagName. Without it every name here would end in the
|
|
4003
|
+
// string 'undefined' and match nothing register wrote.
|
|
4004
|
+
$project: { _id: 0, id: 1, title: 1 }
|
|
3981
4005
|
}
|
|
3982
4006
|
]
|
|
3983
4007
|
});
|
|
3984
|
-
const joined = new Set(segments.map((entry) => entry.
|
|
3985
|
-
const tags = (owned || []).
|
|
3986
|
-
name: tagName(title),
|
|
3987
|
-
status: joined.has(
|
|
4008
|
+
const joined = new Set(segments.map((entry) => entry.id));
|
|
4009
|
+
const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
|
|
4010
|
+
name: tagName(entry.title, entry.id),
|
|
4011
|
+
status: joined.has(entry.id) ? "active" : "inactive"
|
|
3988
4012
|
}));
|
|
3989
4013
|
if (tags.length > 0) {
|
|
3990
4014
|
await api3("/lists/" + audience + "/members/" + hash + "/tags", {
|
|
@@ -4036,7 +4060,7 @@ var mailchimp_default2 = {
|
|
|
4036
4060
|
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
4037
4061
|
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
4038
4062
|
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
4039
|
-
const name = tagName(segment.title);
|
|
4063
|
+
const name = tagName(segment.title, segment.id);
|
|
4040
4064
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
4041
4065
|
let id = null;
|
|
4042
4066
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -4142,7 +4166,7 @@ var mailchimp_default2 = {
|
|
|
4142
4166
|
"/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
|
|
4143
4167
|
{ dc: settings == null ? void 0 : settings.dc, fetcher, token }
|
|
4144
4168
|
);
|
|
4145
|
-
const audiences = ((body == null ? void 0 : body.lists) || []).map((
|
|
4169
|
+
const audiences = ((body == null ? void 0 : body.lists) || []).map((list2) => ({ id: list2.id, title: (list2 == null ? void 0 : list2.name) || list2.id }));
|
|
4146
4170
|
const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
|
|
4147
4171
|
const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
|
|
4148
4172
|
const nextOffset = offset + count;
|
|
@@ -4589,10 +4613,6 @@ var shopify_default2 = {
|
|
|
4589
4613
|
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
4590
4614
|
if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
4591
4615
|
const adminAccessToken = await adminToken();
|
|
4592
|
-
if (!context.shopifyCustomerId) {
|
|
4593
|
-
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
|
|
4594
|
-
if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
4595
|
-
}
|
|
4596
4616
|
const discountCode = await shopify.admin.createDiscountCode({
|
|
4597
4617
|
adminAccessToken,
|
|
4598
4618
|
code: "DB-" + generateDiscountCode(),
|