@drawbridge/drawbridge-utils 0.0.168 → 0.0.170
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 +795 -97
- package/dist/connections/index.d.cts +1216 -96
- package/dist/connections/index.d.ts +1216 -96
- package/dist/connections/index.js +784 -86
- package/dist/providers.cjs +799 -101
- package/dist/providers.js +788 -90
- package/package.json +1 -1
package/dist/providers.cjs
CHANGED
|
@@ -178,7 +178,20 @@ var HOOKS = Object.freeze({
|
|
|
178
178
|
"digest"
|
|
179
179
|
]),
|
|
180
180
|
sms: Object.freeze(["send"]),
|
|
181
|
-
segment: Object.freeze([
|
|
181
|
+
segment: Object.freeze([
|
|
182
|
+
// Make this segment's object exist at the vendor, carrying the segment's
|
|
183
|
+
// current title, and describe the row that points at it. IDEMPOTENT: the
|
|
184
|
+
// same call creates it, renames it after an edit, and backfills a segment
|
|
185
|
+
// that predates the connection — so one hook serves every path and there
|
|
186
|
+
// is no create-vs-update branch to keep in step.
|
|
187
|
+
"register",
|
|
188
|
+
// Remove the vendor object this connection's row points at. Called with
|
|
189
|
+
// the pre-image on a segment delete, because by then the document is gone.
|
|
190
|
+
"remove",
|
|
191
|
+
// Recalculate Drawbridge-side membership. Private to the drawbridge
|
|
192
|
+
// manifest; a vendor does not own who is in a Drawbridge segment.
|
|
193
|
+
"sync"
|
|
194
|
+
]),
|
|
182
195
|
// OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
|
|
183
196
|
// The Webhooks connection is the only thing here with no third party behind
|
|
184
197
|
// it, and the destination is per STEP rather than per connection.
|
|
@@ -287,6 +300,8 @@ var STEPS = Object.freeze({
|
|
|
287
300
|
"email.digest": "Digest",
|
|
288
301
|
"email.notify": "Notification",
|
|
289
302
|
"email.send": "Send email",
|
|
303
|
+
"segment.register": "Register segment",
|
|
304
|
+
"segment.remove": "Remove segment",
|
|
290
305
|
"segment.sync": "Sync segment",
|
|
291
306
|
"sms.send": "Send SMS",
|
|
292
307
|
"webhook.send": "Send webhook"
|
|
@@ -422,7 +437,16 @@ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
|
|
|
422
437
|
...tokens.expiresIn && {
|
|
423
438
|
expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
|
|
424
439
|
},
|
|
425
|
-
|
|
440
|
+
// A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
|
|
441
|
+
// authorization_code response and documents no response body at all for the
|
|
442
|
+
// refresh grant, so taking the minted value alone drops the stored one. That
|
|
443
|
+
// matters because `scope` is load-bearing: the segment hooks gate on
|
|
444
|
+
// `segments:write` and answer `skipped` when it is absent, so a connection
|
|
445
|
+
// that dropped it disables its whole segment half without failing anything
|
|
446
|
+
// and shows a reconnect task that reconnecting has already fixed.
|
|
447
|
+
...(tokens.scope || existing.scope) && {
|
|
448
|
+
scope: tokens.scope || existing.scope
|
|
449
|
+
}
|
|
426
450
|
});
|
|
427
451
|
var accessToken = async ({
|
|
428
452
|
clientId,
|
|
@@ -485,6 +509,98 @@ var detectCountry = (value) => {
|
|
|
485
509
|
}
|
|
486
510
|
};
|
|
487
511
|
|
|
512
|
+
// lib/connections/segment-rows.js
|
|
513
|
+
var import_node_crypto2 = require("crypto");
|
|
514
|
+
var row = ({ connection: connection2, data: data2, manifest, row: described }) => {
|
|
515
|
+
var _a, _b;
|
|
516
|
+
return {
|
|
517
|
+
connection: connection2 == null ? void 0 : connection2.id,
|
|
518
|
+
// ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
|
|
519
|
+
// strings, and one type in the schema is one comparison in the $or below.
|
|
520
|
+
id: String(described == null ? void 0 : described.id),
|
|
521
|
+
slug: connection2 == null ? void 0 : connection2.slug,
|
|
522
|
+
type: described == null ? void 0 : described.type,
|
|
523
|
+
// NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
|
|
524
|
+
// The url is built HERE, while the settings are decrypted and the vendor
|
|
525
|
+
// facts are in hand — an api reading the row later has neither.
|
|
526
|
+
url: ((_b = (_a = manifest == null ? void 0 : manifest.urls) == null ? void 0 : _a.segment) == null ? void 0 : _b.call(_a, { ...described, id: String(described == null ? void 0 : described.id) }, data2)) || null
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
|
|
530
|
+
const built = row({ connection: connection2, data: data2, manifest, row: described });
|
|
531
|
+
return [
|
|
532
|
+
// PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
|
|
533
|
+
// add nothing rather than a duplicate row for one connection.
|
|
534
|
+
{
|
|
535
|
+
collection: "segment",
|
|
536
|
+
data: { $push: { connections: built } },
|
|
537
|
+
operation: "update",
|
|
538
|
+
query: {
|
|
539
|
+
id: segment == null ? void 0 : segment.id,
|
|
540
|
+
"connections.connection": { $ne: connection2 == null ? void 0 : connection2.id }
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
// SET IF DIFFERENT. $elemMatch selects this connection's row only when one
|
|
544
|
+
// of its three mutable fields disagrees, so the steady state — the same
|
|
545
|
+
// vendor object, the same url — matches nothing and writes nothing.
|
|
546
|
+
{
|
|
547
|
+
collection: "segment",
|
|
548
|
+
data: { $set: { "connections.$": built } },
|
|
549
|
+
operation: "update",
|
|
550
|
+
query: {
|
|
551
|
+
id: segment == null ? void 0 : segment.id,
|
|
552
|
+
connections: {
|
|
553
|
+
$elemMatch: {
|
|
554
|
+
connection: connection2 == null ? void 0 : connection2.id,
|
|
555
|
+
$or: [
|
|
556
|
+
{ id: { $ne: built.id } },
|
|
557
|
+
{ type: { $ne: built.type } },
|
|
558
|
+
{ url: { $ne: built.url } }
|
|
559
|
+
]
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
];
|
|
565
|
+
};
|
|
566
|
+
var segmentRowRemoveWrites = ({ connection: connection2, segment }) => [
|
|
567
|
+
{
|
|
568
|
+
collection: "segment",
|
|
569
|
+
data: { $pull: { connections: { connection: connection2 == null ? void 0 : connection2.id } } },
|
|
570
|
+
operation: "update",
|
|
571
|
+
query: { id: segment == null ? void 0 : segment.id }
|
|
572
|
+
}
|
|
573
|
+
];
|
|
574
|
+
var segmentRowFor = ({ connection: connection2, segment }) => ((segment == null ? void 0 : segment.connections) || []).find((entry) => (entry == null ? void 0 : entry.connection) === (connection2 == null ? void 0 : connection2.id));
|
|
575
|
+
var currentSegment = async ({ read, segment }) => {
|
|
576
|
+
if (!(read == null ? void 0 : read.get) || !(segment == null ? void 0 : segment.id)) return segment;
|
|
577
|
+
return read.get({
|
|
578
|
+
collection: "segment",
|
|
579
|
+
query: { id: segment.id }
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
var driftEnqueues = async ({ applied, read, segment, workflow }) => {
|
|
583
|
+
if (!(workflow == null ? void 0 : workflow.id)) return [];
|
|
584
|
+
const fresh = await currentSegment({ read, segment });
|
|
585
|
+
if (!(fresh == null ? void 0 : fresh.id) || fresh.title === applied) return [];
|
|
586
|
+
return [{
|
|
587
|
+
data: {
|
|
588
|
+
triggerData: {
|
|
589
|
+
organization: fresh.organization || (workflow == null ? void 0 : workflow.organization),
|
|
590
|
+
segment: fresh
|
|
591
|
+
},
|
|
592
|
+
workflowId: workflow == null ? void 0 : workflow.id
|
|
593
|
+
},
|
|
594
|
+
name: "execute",
|
|
595
|
+
options: {
|
|
596
|
+
jobId: "workflow.insert.execute." + (workflow == null ? void 0 : workflow.id) + ".segment.register." + fresh.id + ".drift." + Date.now() + "." + (0, import_node_crypto2.randomUUID)().slice(0, 8),
|
|
597
|
+
removeOnComplete: true,
|
|
598
|
+
removeOnFail: true
|
|
599
|
+
},
|
|
600
|
+
queue: "workflow"
|
|
601
|
+
}];
|
|
602
|
+
};
|
|
603
|
+
|
|
488
604
|
// lib/connections/providers/attentive.js
|
|
489
605
|
var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
490
606
|
const response = await fetcher("https://api.attentivemobile.com" + path, {
|
|
@@ -537,7 +653,7 @@ var attentive_default2 = {
|
|
|
537
653
|
// has to say so rather than let them believe otherwise.
|
|
538
654
|
confirm: "Disconnecting removes Drawbridge's stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge \u2014 neither list is deleted.",
|
|
539
655
|
description: [
|
|
540
|
-
"
|
|
656
|
+
"This connection syncs the contacts your campaigns collect into your Attentive account \u2014 subscribed for marketing, and added to the segment you choose.",
|
|
541
657
|
"You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
|
|
542
658
|
"Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
543
659
|
"Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs."
|
|
@@ -579,10 +695,9 @@ var attentive_default2 = {
|
|
|
579
695
|
}
|
|
580
696
|
],
|
|
581
697
|
group: "contacts",
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
// and contacts.sync are the first to flip.
|
|
698
|
+
// WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
|
|
699
|
+
// a paragraph up here that goes stale the moment one of them is implemented
|
|
700
|
+
// — which is exactly what happened to the note this replaces.
|
|
586
701
|
hooks: {
|
|
587
702
|
auth: {
|
|
588
703
|
// FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
|
|
@@ -595,7 +710,7 @@ var attentive_default2 = {
|
|
|
595
710
|
// nobody re-derives it. Klaviyo's connect reads the account name back so
|
|
596
711
|
// the card is not blank; Attentive's card stays blank. There IS an
|
|
597
712
|
// endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
|
|
598
|
-
// on docs.attentive.com/
|
|
713
|
+
// on docs.attentive.com/docs/authentication as returning "information
|
|
599
714
|
// specific to your company" — but its RESPONSE SCHEMA is published
|
|
600
715
|
// nowhere we can read: the docs show the curl and no body. Reading
|
|
601
716
|
// `body.name` would be a guess, and a guess here fails at the worst
|
|
@@ -762,7 +877,62 @@ var attentive_default2 = {
|
|
|
762
877
|
products: false,
|
|
763
878
|
promotions: false
|
|
764
879
|
},
|
|
765
|
-
segment:
|
|
880
|
+
segment: {
|
|
881
|
+
// A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
|
|
882
|
+
// one with an externalId we choose (docs.attentive.com/reference/
|
|
883
|
+
// createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
|
|
884
|
+
// required, `externalId` optional and "auto-generated if not supplied"),
|
|
885
|
+
// which would give a real per-segment object — but it takes
|
|
886
|
+
// segments:write, and scopes ride on the app registration, which does not
|
|
887
|
+
// exist yet.
|
|
888
|
+
//
|
|
889
|
+
// So the row points at the connection-level segment the merchant chose,
|
|
890
|
+
// `type` says so, and turning this into a per-segment object later is a
|
|
891
|
+
// change to this file and nothing else: create with
|
|
892
|
+
// externalId = segment.id, PATCH to rename, archive on remove. Their
|
|
893
|
+
// update and archive endpoints are BOTH keyed by external id
|
|
894
|
+
// (docs.attentive.com/reference/patchsegmentbyexternalid.md and
|
|
895
|
+
// /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
|
|
896
|
+
// already hold addresses every one of the three calls.
|
|
897
|
+
//
|
|
898
|
+
// NO DRIFT CHECK, unlike the other two: the row points at the
|
|
899
|
+
// connection's own segment and the link is the index page, so nothing
|
|
900
|
+
// here depends on the Drawbridge segment's title — a rename has nothing
|
|
901
|
+
// to apply and nothing to race with. That comes back with the
|
|
902
|
+
// per-segment object.
|
|
903
|
+
//
|
|
904
|
+
// THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
|
|
905
|
+
// the simplest of the three registers and therefore the one the next
|
|
906
|
+
// vendor gets copied from — one job id serves four dispatch sites, so a
|
|
907
|
+
// copy that trusts context.segment applies whichever trigger data won
|
|
908
|
+
// the race, at a vendor where the title does matter.
|
|
909
|
+
register: async ({ connection: connection2, context, manifest, settings }, { read } = {}) => {
|
|
910
|
+
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
911
|
+
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
912
|
+
if (!(settings == null ? void 0 : settings.segment)) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
|
|
913
|
+
return {
|
|
914
|
+
events: [{
|
|
915
|
+
event: "organization.segments",
|
|
916
|
+
payload: { id: segment.id },
|
|
917
|
+
room: "organization." + (connection2 == null ? void 0 : connection2.organization)
|
|
918
|
+
}],
|
|
919
|
+
message: "Contacts in this segment are added to the Attentive segment chosen on this connection.",
|
|
920
|
+
writes: segmentRowWrites({
|
|
921
|
+
connection: connection2,
|
|
922
|
+
data: { ...connection2, settings },
|
|
923
|
+
manifest,
|
|
924
|
+
row: { id: settings.segment, type: "segment" },
|
|
925
|
+
segment
|
|
926
|
+
})
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
// NOT OURS TO DELETE. The segment on this connection is the merchant's,
|
|
930
|
+
// and it is where every Drawbridge segment's contacts go — removing it
|
|
931
|
+
// because one Drawbridge segment was deleted would empty the others.
|
|
932
|
+
remove: false,
|
|
933
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
934
|
+
sync: false
|
|
935
|
+
},
|
|
766
936
|
sms: false,
|
|
767
937
|
webhook: false
|
|
768
938
|
},
|
|
@@ -791,6 +961,21 @@ var attentive_default2 = {
|
|
|
791
961
|
"ATTENTIVE_OAUTH_CLIENT_ID",
|
|
792
962
|
"ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
793
963
|
],
|
|
964
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
965
|
+
review: {
|
|
966
|
+
api: "https://docs.attentive.com/reference/listsegments",
|
|
967
|
+
dashboard: "https://docs.attentive.com/docs/segments",
|
|
968
|
+
// THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
|
|
969
|
+
// events:write, ecommerce:write, subscriptions:write, attributes:write,
|
|
970
|
+
// privacy_requests:write — and says nothing about segments:read or
|
|
971
|
+
// segments:write, which the segments API this manifest calls does take.
|
|
972
|
+
// The header at the top of this file carries that distinction; it is
|
|
973
|
+
// repeated here so a reviewer following the link is not misled by what the
|
|
974
|
+
// table omits (fetched 2026-09-11).
|
|
975
|
+
scopes: "https://docs.attentive.com/docs/authentication",
|
|
976
|
+
content: "2026-09-11",
|
|
977
|
+
verified: null
|
|
978
|
+
},
|
|
794
979
|
slug: "attentive",
|
|
795
980
|
// A consent with no segment chosen is authenticated and inert — the sync needs
|
|
796
981
|
// somewhere to put people — so the card says Pending rather than Active over
|
|
@@ -828,6 +1013,24 @@ var attentive_default2 = {
|
|
|
828
1013
|
triggers: ["lead.insert", "segment.contact.add"],
|
|
829
1014
|
usage: { actions: 1 }
|
|
830
1015
|
})
|
|
1016
|
+
},
|
|
1017
|
+
segment: {
|
|
1018
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
1019
|
+
// this fires from the segment's own lifecycle, not from a workflow
|
|
1020
|
+
// somebody assembled. The trigger is declared here rather than hard-coded
|
|
1021
|
+
// in drawbridge-sync.
|
|
1022
|
+
//
|
|
1023
|
+
// REGISTER ONLY. There is no remove step because hooks.segment.remove is
|
|
1024
|
+
// declined, and build() refuses a step pointing at a hook this vendor does
|
|
1025
|
+
// not implement — so the two are one decision, enforced at import.
|
|
1026
|
+
register: () => ({
|
|
1027
|
+
description: "Records which Attentive segment a Drawbridge segment's contacts are added to.",
|
|
1028
|
+
hook: "segment.register",
|
|
1029
|
+
key: "Attentive Segment Register",
|
|
1030
|
+
queue: "connection",
|
|
1031
|
+
system: true,
|
|
1032
|
+
trigger: { event: "segment.register", type: "event" }
|
|
1033
|
+
})
|
|
831
1034
|
}
|
|
832
1035
|
},
|
|
833
1036
|
// WHY, in the merchant's words, and what to do about it.
|
|
@@ -844,14 +1047,28 @@ var attentive_default2 = {
|
|
|
844
1047
|
}
|
|
845
1048
|
];
|
|
846
1049
|
},
|
|
847
|
-
title: "Attentive"
|
|
1050
|
+
title: "Attentive",
|
|
1051
|
+
// ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
|
|
1052
|
+
// only identifier we hold is the API's externalId, which their UI may not
|
|
1053
|
+
// path by — so this lands on the list, where the merchant finds it by name.
|
|
1054
|
+
// A per-segment link arrives with the per-segment object (see hooks.segment).
|
|
1055
|
+
//
|
|
1056
|
+
// THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
|
|
1057
|
+
// segment url carries. What is on record is that the segments area lives at
|
|
1058
|
+
// ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
|
|
1059
|
+
// sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
|
|
1060
|
+
// tab is not, and Attentive's help centre refuses automated fetches. The dev
|
|
1061
|
+
// walk-through confirms this against a real account before promote.
|
|
1062
|
+
urls: {
|
|
1063
|
+
segment: () => "https://ui.attentivemobile.com/segments/all"
|
|
1064
|
+
}
|
|
848
1065
|
};
|
|
849
1066
|
|
|
850
1067
|
// lib/connections/providers/drawbridge.js
|
|
851
|
-
var
|
|
1068
|
+
var import_node_crypto4 = require("crypto");
|
|
852
1069
|
|
|
853
1070
|
// lib/connections/inbound.js
|
|
854
|
-
var
|
|
1071
|
+
var import_node_crypto3 = require("crypto");
|
|
855
1072
|
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
856
1073
|
if (!secret) {
|
|
857
1074
|
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
@@ -860,10 +1077,10 @@ var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
|
860
1077
|
if (!provided) {
|
|
861
1078
|
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
862
1079
|
}
|
|
863
|
-
const digest = (0,
|
|
1080
|
+
const digest = (0, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
864
1081
|
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
865
1082
|
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
866
|
-
if (digestBuffer.length !== providedBuffer.length || !(0,
|
|
1083
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
867
1084
|
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
868
1085
|
}
|
|
869
1086
|
return JSON.parse(body.toString());
|
|
@@ -888,7 +1105,7 @@ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
|
|
|
888
1105
|
throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
|
|
889
1106
|
}
|
|
890
1107
|
const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
|
|
891
|
-
const verified = (0,
|
|
1108
|
+
const verified = (0, import_node_crypto3.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
|
|
892
1109
|
if (!verified) {
|
|
893
1110
|
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
894
1111
|
}
|
|
@@ -1964,7 +2181,7 @@ var drawbridge_default2 = {
|
|
|
1964
2181
|
content: {
|
|
1965
2182
|
confirm: "This connection is part of Drawbridge and cannot be disconnected.",
|
|
1966
2183
|
description: [
|
|
1967
|
-
"Drawbridge sends your notification
|
|
2184
|
+
"Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
|
|
1968
2185
|
],
|
|
1969
2186
|
excerpt: "The steps Drawbridge runs itself.",
|
|
1970
2187
|
guide: [
|
|
@@ -2262,9 +2479,9 @@ var drawbridge_default2 = {
|
|
|
2262
2479
|
if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
|
|
2263
2480
|
const params = new URLSearchParams(String(body || ""));
|
|
2264
2481
|
const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
|
|
2265
|
-
const expected = (0,
|
|
2482
|
+
const expected = (0, import_node_crypto4.createHmac)("sha1", secret).update(signed).digest("base64");
|
|
2266
2483
|
const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
|
|
2267
|
-
const matches = expected.length === provided.length && (0,
|
|
2484
|
+
const matches = expected.length === provided.length && (0, import_node_crypto4.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
|
|
2268
2485
|
if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
|
|
2269
2486
|
return Object.fromEntries(params);
|
|
2270
2487
|
}
|
|
@@ -2277,6 +2494,11 @@ var drawbridge_default2 = {
|
|
|
2277
2494
|
promotions: false
|
|
2278
2495
|
},
|
|
2279
2496
|
segment: {
|
|
2497
|
+
// NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
|
|
2498
|
+
// vendor, and this manifest has no vendor behind it — the three that do
|
|
2499
|
+
// implement these.
|
|
2500
|
+
register: false,
|
|
2501
|
+
remove: false,
|
|
2280
2502
|
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
2281
2503
|
// contact in an organization against every segment, which is too much for
|
|
2282
2504
|
// one job, so it returns chunks and the shell defers completion.
|
|
@@ -2552,6 +2774,33 @@ var drawbridge_default2 = {
|
|
|
2552
2774
|
// caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
|
|
2553
2775
|
// empty the moment this was added.
|
|
2554
2776
|
requires: [],
|
|
2777
|
+
// PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
|
|
2778
|
+
// manifest, so `false` would be a lie about which reads were made.
|
|
2779
|
+
//
|
|
2780
|
+
// ONE ENTRY PER VENDOR, because three vendors are three reads. A single
|
|
2781
|
+
// citation here would evidence one of them and read as though it covered all
|
|
2782
|
+
// three, which is the omission this key exists to catch.
|
|
2783
|
+
review: {
|
|
2784
|
+
api: {
|
|
2785
|
+
// lib/hubspot.js posts to /crm/v3/objects/contacts.
|
|
2786
|
+
hubspot: "https://developers.hubspot.com/docs/reference/api/crm/objects/contacts",
|
|
2787
|
+
// lib/sendgrid.js posts to /v3/mail/send.
|
|
2788
|
+
sendgrid: "https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send",
|
|
2789
|
+
// lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
|
|
2790
|
+
// hooks.inbound.verify reads the MessageStatus this resource documents.
|
|
2791
|
+
twilio: "https://www.twilio.com/docs/messaging/api/message-resource"
|
|
2792
|
+
},
|
|
2793
|
+
dashboard: {
|
|
2794
|
+
hubspot: "https://knowledge.hubspot.com/contacts/create-contacts",
|
|
2795
|
+
sendgrid: "https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed",
|
|
2796
|
+
twilio: "https://www.twilio.com/docs/messaging/guides/debugging-tools"
|
|
2797
|
+
},
|
|
2798
|
+
// An admin types these keys in; there is no merchant consent and no scope
|
|
2799
|
+
// model on any of the three.
|
|
2800
|
+
scopes: false,
|
|
2801
|
+
content: "2026-09-11",
|
|
2802
|
+
verified: null
|
|
2803
|
+
},
|
|
2555
2804
|
slug: "drawbridge",
|
|
2556
2805
|
// Always on. There is no credential that could go bad and no configuration a
|
|
2557
2806
|
// merchant could leave half-finished.
|
|
@@ -2743,6 +2992,8 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
|
|
|
2743
2992
|
}
|
|
2744
2993
|
return response.status === 204 ? null : response.json();
|
|
2745
2994
|
};
|
|
2995
|
+
var segmentName = (title) => "Drawbridge: " + title;
|
|
2996
|
+
var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
|
|
2746
2997
|
var klaviyo_default2 = {
|
|
2747
2998
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
2748
2999
|
// exchange without a code_verifier matching the challenge the consent
|
|
@@ -2772,9 +3023,21 @@ var klaviyo_default2 = {
|
|
|
2772
3023
|
// exchange, and a copy here would be a second answer that goes stale.
|
|
2773
3024
|
expiry: 90 * 24 * 60 * 60,
|
|
2774
3025
|
pkce: true,
|
|
3026
|
+
// EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
|
|
3027
|
+
// Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
|
|
3028
|
+
// and set them using a space-separated list"
|
|
3029
|
+
// (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
|
|
3030
|
+
// 2026-09-11) — and a merchant's token only ever carries what they
|
|
3031
|
+
// consented to, so a scope added later is a reconnect for every one of
|
|
3032
|
+
// them. That is what segments cost when they were left out here.
|
|
3033
|
+
//
|
|
2775
3034
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
2776
|
-
// and must stay in the list; the rest are what a contact sync
|
|
2777
|
-
|
|
3035
|
+
// and must stay in the list; the rest are what a contact sync and the
|
|
3036
|
+
// segment hooks need — Get Segments lists `segments:read`, Create,
|
|
3037
|
+
// Update and Delete Segment each list `segments:write`
|
|
3038
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3039
|
+
// revision 2026-07-15, fetched 2026-09-11).
|
|
3040
|
+
scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
|
|
2778
3041
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
2779
3042
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
2780
3043
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -2823,9 +3086,10 @@ var klaviyo_default2 = {
|
|
|
2823
3086
|
// Shown at disconnect, so it says what is lost and what is not.
|
|
2824
3087
|
confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
|
|
2825
3088
|
description: [
|
|
2826
|
-
"Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway
|
|
3089
|
+
"Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.",
|
|
2827
3090
|
"You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.",
|
|
2828
|
-
"Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing."
|
|
3091
|
+
"Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
3092
|
+
"Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them."
|
|
2829
3093
|
],
|
|
2830
3094
|
// KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
|
|
2831
3095
|
// likely to grow — resources.* has already earned somewhere to put "we
|
|
@@ -2982,6 +3246,7 @@ var klaviyo_default2 = {
|
|
|
2982
3246
|
if (!email) return { message: "That lead has no email address to sync.", skipped: true };
|
|
2983
3247
|
const person = (context == null ? void 0 : context.contact) || null;
|
|
2984
3248
|
const totals = (person == null ? void 0 : person.totals) || {};
|
|
3249
|
+
const count = (value) => typeof value === "number" ? value : (value == null ? void 0 : value.total) || 0;
|
|
2985
3250
|
const profile = await api2("/profile-import", {
|
|
2986
3251
|
fetcher,
|
|
2987
3252
|
method: "POST",
|
|
@@ -2995,13 +3260,13 @@ var klaviyo_default2 = {
|
|
|
2995
3260
|
drawbridge_campaigns: (person.campaigns || []).length,
|
|
2996
3261
|
drawbridge_draws: totals.draws || 0,
|
|
2997
3262
|
drawbridge_entries: totals.entries || 0,
|
|
2998
|
-
drawbridge_orders: totals.orders
|
|
3263
|
+
drawbridge_orders: count(totals.orders),
|
|
2999
3264
|
// Campaign-attributed, NOT lifetime. A merchant running
|
|
3000
3265
|
// Shopify already has lifetime revenue in Klaviyo through
|
|
3001
3266
|
// Klaviyo's own integration; what only we can say is how
|
|
3002
3267
|
// much a campaign drove. Named so the two cannot be
|
|
3003
3268
|
// mistaken for one another in a segment builder.
|
|
3004
|
-
drawbridge_revenue: totals.gross
|
|
3269
|
+
drawbridge_revenue: count(totals.gross)
|
|
3005
3270
|
},
|
|
3006
3271
|
// THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
|
|
3007
3272
|
// merchant builds Klaviyo segments on top of. Klaviyo owns no
|
|
@@ -3020,7 +3285,14 @@ var klaviyo_default2 = {
|
|
|
3020
3285
|
// `segments` is null when the run carried no contact document,
|
|
3021
3286
|
// meaning nobody looked — different from [], which means they
|
|
3022
3287
|
// are in none. Null omits the key and merge leaves it alone.
|
|
3023
|
-
...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
|
|
3288
|
+
...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) },
|
|
3289
|
+
// THE IDS, which is what a Drawbridge-made segment's definition
|
|
3290
|
+
// filters on. Ids rather than titles, so renaming a segment is a
|
|
3291
|
+
// name change at Klaviyo and not a resync of every profile.
|
|
3292
|
+
//
|
|
3293
|
+
// The titles stay beside them: merchants have been building
|
|
3294
|
+
// their own segments on that array since it shipped.
|
|
3295
|
+
...segments && { drawbridge_segment_ids: segments.map((entry) => entry.id).filter(Boolean) }
|
|
3024
3296
|
}
|
|
3025
3297
|
},
|
|
3026
3298
|
type: "profile"
|
|
@@ -3064,17 +3336,141 @@ var klaviyo_default2 = {
|
|
|
3064
3336
|
};
|
|
3065
3337
|
}
|
|
3066
3338
|
},
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
//
|
|
3070
|
-
//
|
|
3071
|
-
//
|
|
3072
|
-
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
3073
|
-
// these would be a second sender, which is the arrangement the platform
|
|
3074
|
-
// sender replaced.
|
|
3339
|
+
// Drawbridge sends its own notification email. A vendor answering this
|
|
3340
|
+
// would be a second sender, which is the arrangement the platform sender
|
|
3341
|
+
// replaced. Declined as one line rather than one per verb, because the whole
|
|
3342
|
+
// domain is one decision — still explicit, since absence would not say
|
|
3343
|
+
// whether anybody considered it.
|
|
3075
3344
|
email: false,
|
|
3076
|
-
|
|
3345
|
+
// SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
|
|
3346
|
+
// membership — see the private `drawbridge` manifest, and `sync : false`
|
|
3347
|
+
// below — while register and remove keep a Klaviyo segment standing for
|
|
3348
|
+
// each Drawbridge segment, so the merchant can target one in their own
|
|
3349
|
+
// flows.
|
|
3350
|
+
segment: {
|
|
3351
|
+
// THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
|
|
3352
|
+
//
|
|
3353
|
+
// Klaviyo owns no writable membership — its segments are computed from
|
|
3354
|
+
// rules — so the segment we create is DEFINED BY the profile property
|
|
3355
|
+
// contacts.sync writes. The definition filters on the Drawbridge
|
|
3356
|
+
// segment's ID, never its title, which is what makes a rename one PATCH
|
|
3357
|
+
// instead of a resync of every profile in it.
|
|
3358
|
+
register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
|
|
3359
|
+
var _a, _b, _c, _d, _e;
|
|
3360
|
+
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
3361
|
+
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
3362
|
+
if (!canManageSegments(settings)) {
|
|
3363
|
+
return {
|
|
3364
|
+
message: "Reconnect Klaviyo to let Drawbridge manage segments \u2014 this connection was made before that permission was asked for.",
|
|
3365
|
+
skipped: true
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
const name = segmentName(segment.title);
|
|
3369
|
+
const existing = segmentRowFor({ connection: connection2, segment });
|
|
3370
|
+
let id = null;
|
|
3371
|
+
if (existing == null ? void 0 : existing.id) {
|
|
3372
|
+
try {
|
|
3373
|
+
const found = await api2("/segments/" + existing.id, { fetcher, token });
|
|
3374
|
+
id = ((_a = found == null ? void 0 : found.data) == null ? void 0 : _a.id) ?? existing.id;
|
|
3375
|
+
if (((_c = (_b = found == null ? void 0 : found.data) == null ? void 0 : _b.attributes) == null ? void 0 : _c.name) !== name) {
|
|
3376
|
+
await api2("/segments/" + existing.id, {
|
|
3377
|
+
fetcher,
|
|
3378
|
+
method: "PATCH",
|
|
3379
|
+
payload: { data: { attributes: { name }, id: existing.id, type: "segment" } },
|
|
3380
|
+
token
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
} catch (error) {
|
|
3384
|
+
if (error.status !== 404) throw error;
|
|
3385
|
+
id = null;
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
if (!id) {
|
|
3389
|
+
const search = await api2("/segments?filter=" + encodeURIComponent('equals(name,"' + name.replace(/"/g, '\\"') + '")'), { fetcher, token });
|
|
3390
|
+
id = ((_d = ((search == null ? void 0 : search.data) || []).find((entry) => {
|
|
3391
|
+
var _a2;
|
|
3392
|
+
return ((_a2 = entry == null ? void 0 : entry.attributes) == null ? void 0 : _a2.name) === name;
|
|
3393
|
+
})) == null ? void 0 : _d.id) ?? null;
|
|
3394
|
+
}
|
|
3395
|
+
if (!id) {
|
|
3396
|
+
const created = await api2("/segments", {
|
|
3397
|
+
fetcher,
|
|
3398
|
+
method: "POST",
|
|
3399
|
+
// THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
|
|
3400
|
+
// — `name` and `definition` are both required on its attributes
|
|
3401
|
+
// — and a custom profile property is addressed as
|
|
3402
|
+
// "properties['property name']", tested with a list filter whose
|
|
3403
|
+
// operator is `contains`
|
|
3404
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3405
|
+
// revision 2026-07-15, fetched 2026-09-11).
|
|
3406
|
+
payload: {
|
|
3407
|
+
data: {
|
|
3408
|
+
attributes: {
|
|
3409
|
+
definition: {
|
|
3410
|
+
condition_groups: [{
|
|
3411
|
+
conditions: [{
|
|
3412
|
+
filter: { operator: "contains", type: "list", value: segment.id },
|
|
3413
|
+
property: "properties['drawbridge_segment_ids']",
|
|
3414
|
+
type: "profile-property"
|
|
3415
|
+
}]
|
|
3416
|
+
}]
|
|
3417
|
+
},
|
|
3418
|
+
name
|
|
3419
|
+
},
|
|
3420
|
+
type: "segment"
|
|
3421
|
+
}
|
|
3422
|
+
},
|
|
3423
|
+
token
|
|
3424
|
+
});
|
|
3425
|
+
id = (_e = created == null ? void 0 : created.data) == null ? void 0 : _e.id;
|
|
3426
|
+
}
|
|
3427
|
+
if (!id) return { message: "Klaviyo returned no segment id.", skipped: true };
|
|
3428
|
+
return {
|
|
3429
|
+
// A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
|
|
3430
|
+
// coalescing job id, so the last thing this does is look again.
|
|
3431
|
+
enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
|
|
3432
|
+
events: [{
|
|
3433
|
+
event: "organization.segments",
|
|
3434
|
+
payload: { id: segment.id },
|
|
3435
|
+
room: "organization." + (connection2 == null ? void 0 : connection2.organization)
|
|
3436
|
+
}],
|
|
3437
|
+
message: 'Klaviyo is carrying this segment as "' + name + '".',
|
|
3438
|
+
writes: segmentRowWrites({
|
|
3439
|
+
connection: connection2,
|
|
3440
|
+
data: { ...connection2, settings },
|
|
3441
|
+
manifest,
|
|
3442
|
+
row: { id, type: "segment" },
|
|
3443
|
+
segment
|
|
3444
|
+
})
|
|
3445
|
+
};
|
|
3446
|
+
},
|
|
3447
|
+
// NO RE-READ. The segment is already deleted; the pre-image is the only
|
|
3448
|
+
// copy, and it carries the row naming what to delete.
|
|
3449
|
+
remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
|
|
3450
|
+
const segment = context == null ? void 0 : context.segment;
|
|
3451
|
+
const existing = segmentRowFor({ connection: connection2, segment });
|
|
3452
|
+
if (!(existing == null ? void 0 : existing.id)) return { message: "Klaviyo was never carrying this segment.", skipped: true };
|
|
3453
|
+
if (!canManageSegments(settings)) {
|
|
3454
|
+
return { message: "Reconnect Klaviyo to let Drawbridge manage segments.", skipped: true };
|
|
3455
|
+
}
|
|
3456
|
+
try {
|
|
3457
|
+
await api2("/segments/" + existing.id, { fetcher, method: "DELETE", token });
|
|
3458
|
+
} catch (error) {
|
|
3459
|
+
if (error.status !== 404) throw error;
|
|
3460
|
+
}
|
|
3461
|
+
return {
|
|
3462
|
+
message: "Klaviyo is no longer carrying this segment.",
|
|
3463
|
+
writes: segmentRowRemoveWrites({ connection: connection2, segment })
|
|
3464
|
+
};
|
|
3465
|
+
},
|
|
3466
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
3467
|
+
sync: false
|
|
3468
|
+
},
|
|
3469
|
+
// Declined for the same reason as `email` above: Drawbridge sends its own
|
|
3470
|
+
// notification SMS, and a vendor answering this would be a second sender.
|
|
3077
3471
|
sms: false,
|
|
3472
|
+
// Klaviyo sends us nothing — no inbound message to receive, no signature
|
|
3473
|
+
// to verify.
|
|
3078
3474
|
inbound: false,
|
|
3079
3475
|
// Nothing to set up or tear down at the vendor: the grant is the whole
|
|
3080
3476
|
// integration. What CAN rot is the grant itself, so health is the one
|
|
@@ -3195,6 +3591,18 @@ var klaviyo_default2 = {
|
|
|
3195
3591
|
"KLAVIYO_OAUTH_CLIENT_ID",
|
|
3196
3592
|
"KLAVIYO_OAUTH_CLIENT_SECRET"
|
|
3197
3593
|
],
|
|
3594
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
3595
|
+
review: {
|
|
3596
|
+
api: "https://developers.klaviyo.com/en/reference/api_overview",
|
|
3597
|
+
dashboard: "https://help.klaviyo.com/hc/en-us/articles/115005078647",
|
|
3598
|
+
// THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
|
|
3599
|
+
// example scope string and nothing to check a manifest against; this page
|
|
3600
|
+
// lists the scopes each API takes, segments:read and segments:write among
|
|
3601
|
+
// them (fetched 2026-09-11).
|
|
3602
|
+
scopes: "https://developers.klaviyo.com/en/docs/authenticate_",
|
|
3603
|
+
content: "2026-09-11",
|
|
3604
|
+
verified: null
|
|
3605
|
+
},
|
|
3198
3606
|
slug: "klaviyo",
|
|
3199
3607
|
// ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
|
|
3200
3608
|
// is already the merchant-facing copy channel and is already rendered.
|
|
@@ -3225,7 +3633,8 @@ var klaviyo_default2 = {
|
|
|
3225
3633
|
hook: "lifecycle.health",
|
|
3226
3634
|
key: "Klaviyo Connection Health",
|
|
3227
3635
|
queue: "connection",
|
|
3228
|
-
system: true
|
|
3636
|
+
system: true,
|
|
3637
|
+
trigger: { event: "day", type: "schedule" }
|
|
3229
3638
|
})
|
|
3230
3639
|
}
|
|
3231
3640
|
},
|
|
@@ -3269,6 +3678,28 @@ var klaviyo_default2 = {
|
|
|
3269
3678
|
usage: { actions: 1 }
|
|
3270
3679
|
};
|
|
3271
3680
|
}
|
|
3681
|
+
},
|
|
3682
|
+
segment: {
|
|
3683
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
3684
|
+
// these fire from the segment's own lifecycle, not from a workflow
|
|
3685
|
+
// somebody assembled. The trigger is declared here rather than hard-coded
|
|
3686
|
+
// in drawbridge-sync.
|
|
3687
|
+
register: () => ({
|
|
3688
|
+
description: "Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.",
|
|
3689
|
+
hook: "segment.register",
|
|
3690
|
+
key: "Klaviyo Segment Register",
|
|
3691
|
+
queue: "connection",
|
|
3692
|
+
system: true,
|
|
3693
|
+
trigger: { event: "segment.register", type: "event" }
|
|
3694
|
+
}),
|
|
3695
|
+
remove: () => ({
|
|
3696
|
+
description: "Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.",
|
|
3697
|
+
hook: "segment.remove",
|
|
3698
|
+
key: "Klaviyo Segment Remove",
|
|
3699
|
+
queue: "connection",
|
|
3700
|
+
system: true,
|
|
3701
|
+
trigger: { event: "segment.remove", type: "event" }
|
|
3702
|
+
})
|
|
3272
3703
|
}
|
|
3273
3704
|
},
|
|
3274
3705
|
// WHY, in the merchant's words, and what to do about it.
|
|
@@ -3278,18 +3709,36 @@ var klaviyo_default2 = {
|
|
|
3278
3709
|
// moment: the grant is good and the list is the missing half.
|
|
3279
3710
|
tasks: (data2) => {
|
|
3280
3711
|
var _a;
|
|
3281
|
-
|
|
3282
|
-
|
|
3712
|
+
if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
|
|
3713
|
+
return [
|
|
3714
|
+
// A connection made before segments were requested is authenticated and
|
|
3715
|
+
// cannot manage them, and no error surfaces anywhere else — the register
|
|
3716
|
+
// runs skip rather than fail.
|
|
3717
|
+
...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
|
|
3718
|
+
message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
|
|
3719
|
+
title: "Reconnect Klaviyo"
|
|
3720
|
+
}],
|
|
3721
|
+
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
|
|
3283
3722
|
message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
|
|
3284
3723
|
title: "Choose a list"
|
|
3285
|
-
}
|
|
3724
|
+
}]
|
|
3286
3725
|
];
|
|
3287
3726
|
},
|
|
3288
|
-
title: "Klaviyo"
|
|
3727
|
+
title: "Klaviyo",
|
|
3728
|
+
// KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
|
|
3729
|
+
// is its own help centre on a list: "you can find a list's ID in the URL in
|
|
3730
|
+
// your browser when viewing this list"
|
|
3731
|
+
// (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
|
|
3732
|
+
// segment's page is the sibling form of it. The path itself is NOT published
|
|
3733
|
+
// anywhere citable, so the dev walk-through confirms this against a real
|
|
3734
|
+
// account before promote.
|
|
3735
|
+
urls: {
|
|
3736
|
+
segment: (row2) => (row2 == null ? void 0 : row2.id) ? "https://www.klaviyo.com/segment/" + row2.id : null
|
|
3737
|
+
}
|
|
3289
3738
|
};
|
|
3290
3739
|
|
|
3291
3740
|
// lib/connections/providers/mailchimp.js
|
|
3292
|
-
var
|
|
3741
|
+
var import_node_crypto5 = require("crypto");
|
|
3293
3742
|
|
|
3294
3743
|
// lib/connections/icons/mailchimp.js
|
|
3295
3744
|
var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -3321,7 +3770,9 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
3321
3770
|
}
|
|
3322
3771
|
return response.status === 204 ? null : response.json();
|
|
3323
3772
|
};
|
|
3324
|
-
var subscriberHash = (email) => (0,
|
|
3773
|
+
var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
3774
|
+
var TAG_NAME_LIMIT = 100;
|
|
3775
|
+
var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
|
|
3325
3776
|
var mailchimp_default2 = {
|
|
3326
3777
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
3327
3778
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -3364,7 +3815,7 @@ var mailchimp_default2 = {
|
|
|
3364
3815
|
confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
|
|
3365
3816
|
description: [
|
|
3366
3817
|
"Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.",
|
|
3367
|
-
"Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway
|
|
3818
|
+
"Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so you can market to the people who enter a giveaway alongside the rest of your list.",
|
|
3368
3819
|
"Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
3369
3820
|
"Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
|
|
3370
3821
|
],
|
|
@@ -3377,6 +3828,7 @@ var mailchimp_default2 = {
|
|
|
3377
3828
|
"Sign in to Mailchimp if you are not already, and choose the account to connect.",
|
|
3378
3829
|
"You come back here to pick the audience your contacts should sync into.",
|
|
3379
3830
|
"The connection shows Pending until you pick an audience, then Active.",
|
|
3831
|
+
'Each of your Drawbridge segments appears in that audience as a tag named "Drawbridge: " plus the segment name. Renaming a segment in Drawbridge renames its tag, and deleting the segment deletes the tag.',
|
|
3380
3832
|
"You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
|
|
3381
3833
|
]
|
|
3382
3834
|
},
|
|
@@ -3402,10 +3854,9 @@ var mailchimp_default2 = {
|
|
|
3402
3854
|
}
|
|
3403
3855
|
],
|
|
3404
3856
|
group: "contacts",
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
//
|
|
3408
|
-
// contacts.sync are the first to flip.
|
|
3857
|
+
// WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
|
|
3858
|
+
// a paragraph up here that goes stale the moment one of them is implemented
|
|
3859
|
+
// — which is exactly what happened to the note this replaces.
|
|
3409
3860
|
hooks: {
|
|
3410
3861
|
auth: {
|
|
3411
3862
|
// WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
|
|
@@ -3459,26 +3910,32 @@ var mailchimp_default2 = {
|
|
|
3459
3910
|
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
3460
3911
|
// Marketing API reference for the list-members resource.
|
|
3461
3912
|
sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
|
|
3462
|
-
var _a, _b;
|
|
3913
|
+
var _a, _b, _c;
|
|
3463
3914
|
const audience = settings == null ? void 0 : settings.audience;
|
|
3464
3915
|
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
3465
3916
|
const 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);
|
|
3466
3917
|
if (!email) return { message: "That lead has no email address to sync.", skipped: true };
|
|
3467
3918
|
const hash = subscriberHash(email);
|
|
3919
|
+
const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
|
|
3920
|
+
const lastName = restOfName.join(" ");
|
|
3921
|
+
const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
|
|
3922
|
+
const mergeFields = {
|
|
3923
|
+
...firstName && { FNAME: firstName },
|
|
3924
|
+
...lastName && { LNAME: lastName },
|
|
3925
|
+
...phone && { PHONE: phone }
|
|
3926
|
+
};
|
|
3468
3927
|
const member = await api3("/lists/" + audience + "/members/" + hash, {
|
|
3469
3928
|
dc: settings == null ? void 0 : settings.dc,
|
|
3470
3929
|
fetcher,
|
|
3471
3930
|
method: "PUT",
|
|
3472
3931
|
payload: {
|
|
3473
3932
|
email_address: email,
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
3477
|
-
//
|
|
3478
|
-
//
|
|
3479
|
-
|
|
3480
|
-
// job and is not built.
|
|
3481
|
-
...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
|
|
3933
|
+
// Built above. Omitted entirely when there is nothing to say, so a
|
|
3934
|
+
// lead with only an address does not send an empty object. The
|
|
3935
|
+
// Drawbridge totals Klaviyo receives still cannot travel this way —
|
|
3936
|
+
// those are custom tags, and registering them on the chosen audience
|
|
3937
|
+
// is lifecycle.register's job and is not built.
|
|
3938
|
+
...Object.keys(mergeFields).length > 0 && { merge_fields: mergeFields },
|
|
3482
3939
|
...suppressed && { status: "unsubscribed" },
|
|
3483
3940
|
status_if_new: suppressed ? "unsubscribed" : "subscribed"
|
|
3484
3941
|
},
|
|
@@ -3501,7 +3958,7 @@ var mailchimp_default2 = {
|
|
|
3501
3958
|
});
|
|
3502
3959
|
const joined = new Set(segments.map((entry) => entry.title));
|
|
3503
3960
|
const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
|
|
3504
|
-
name:
|
|
3961
|
+
name: tagName(title),
|
|
3505
3962
|
status: joined.has(title) ? "active" : "inactive"
|
|
3506
3963
|
}));
|
|
3507
3964
|
if (tags.length > 0) {
|
|
@@ -3524,12 +3981,122 @@ var mailchimp_default2 = {
|
|
|
3524
3981
|
};
|
|
3525
3982
|
}
|
|
3526
3983
|
},
|
|
3527
|
-
// Drawbridge sends its own notification email
|
|
3528
|
-
//
|
|
3529
|
-
//
|
|
3530
|
-
// sender replaced.
|
|
3984
|
+
// Drawbridge sends its own notification email. A vendor answering this
|
|
3985
|
+
// would be a second sender, which is the arrangement the platform sender
|
|
3986
|
+
// replaced.
|
|
3531
3987
|
email: false,
|
|
3532
|
-
|
|
3988
|
+
// SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
|
|
3989
|
+
// membership — see the private `drawbridge` manifest, and `sync : false`
|
|
3990
|
+
// below — while register and remove keep a Mailchimp tag standing for each
|
|
3991
|
+
// Drawbridge segment, so the merchant can target one in their own audience.
|
|
3992
|
+
segment: {
|
|
3993
|
+
// THE TAG THIS SEGMENT IS, held by id at last.
|
|
3994
|
+
//
|
|
3995
|
+
// Tags ARE static segments in Mailchimp's model — same collection, same
|
|
3996
|
+
// ids — so this creates one through /segments and the member write goes
|
|
3997
|
+
// on attaching people to it by name. Both address the same object. The
|
|
3998
|
+
// segment schema says it outright: "The type of segment. Static segments
|
|
3999
|
+
// are now known as tags"
|
|
4000
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
|
|
4001
|
+
// fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
|
|
4002
|
+
// into fragment files like this one).
|
|
4003
|
+
//
|
|
4004
|
+
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
|
|
4005
|
+
// finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
|
|
4006
|
+
// all four without a create-vs-update branch anywhere else.
|
|
4007
|
+
register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
|
|
4008
|
+
var _a;
|
|
4009
|
+
const audience = settings == null ? void 0 : settings.audience;
|
|
4010
|
+
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
4011
|
+
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
4012
|
+
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
4013
|
+
const name = tagName(segment.title);
|
|
4014
|
+
const existing = segmentRowFor({ connection: connection2, segment });
|
|
4015
|
+
let id = null;
|
|
4016
|
+
if (existing == null ? void 0 : existing.id) {
|
|
4017
|
+
try {
|
|
4018
|
+
const found = await api3("/lists/" + audience + "/segments/" + existing.id, { dc: settings == null ? void 0 : settings.dc, fetcher, token });
|
|
4019
|
+
id = (found == null ? void 0 : found.id) ?? existing.id;
|
|
4020
|
+
if ((found == null ? void 0 : found.name) !== name) {
|
|
4021
|
+
await api3("/lists/" + audience + "/segments/" + existing.id, {
|
|
4022
|
+
dc: settings == null ? void 0 : settings.dc,
|
|
4023
|
+
fetcher,
|
|
4024
|
+
method: "PATCH",
|
|
4025
|
+
payload: { name },
|
|
4026
|
+
token
|
|
4027
|
+
});
|
|
4028
|
+
}
|
|
4029
|
+
} catch (error) {
|
|
4030
|
+
if (error.status !== 404) throw error;
|
|
4031
|
+
id = null;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
if (!id) {
|
|
4035
|
+
const search = await api3("/lists/" + audience + "/tag-search?name=" + encodeURIComponent(name), { dc: settings == null ? void 0 : settings.dc, fetcher, token });
|
|
4036
|
+
id = ((_a = ((search == null ? void 0 : search.tags) || []).find((tag) => (tag == null ? void 0 : tag.name) === name)) == null ? void 0 : _a.id) ?? null;
|
|
4037
|
+
}
|
|
4038
|
+
if (!id) {
|
|
4039
|
+
const created = await api3("/lists/" + audience + "/segments", {
|
|
4040
|
+
dc: settings == null ? void 0 : settings.dc,
|
|
4041
|
+
fetcher,
|
|
4042
|
+
method: "POST",
|
|
4043
|
+
// STATIC WITH NO MEMBERS. The member sync attaches people by
|
|
4044
|
+
// name; this call only has to make the object exist. Mailchimp's
|
|
4045
|
+
// own wording for the empty array: "Passing an empty array will
|
|
4046
|
+
// create a static segment without any subscribers."
|
|
4047
|
+
payload: { name, static_segment: [] },
|
|
4048
|
+
token
|
|
4049
|
+
});
|
|
4050
|
+
id = created == null ? void 0 : created.id;
|
|
4051
|
+
}
|
|
4052
|
+
if (!id) return { message: "Mailchimp returned no tag id.", skipped: true };
|
|
4053
|
+
const audienceDetail = await api3("/lists/" + audience + "?fields=web_id", { dc: settings == null ? void 0 : settings.dc, fetcher, token });
|
|
4054
|
+
return {
|
|
4055
|
+
// A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
|
|
4056
|
+
// coalescing job id, so the last thing this does is look again.
|
|
4057
|
+
enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
|
|
4058
|
+
events: [{
|
|
4059
|
+
event: "organization.segments",
|
|
4060
|
+
payload: { id: segment.id },
|
|
4061
|
+
room: "organization." + (connection2 == null ? void 0 : connection2.organization)
|
|
4062
|
+
}],
|
|
4063
|
+
message: 'Mailchimp is carrying this segment as the tag "' + name + '".',
|
|
4064
|
+
writes: segmentRowWrites({
|
|
4065
|
+
connection: connection2,
|
|
4066
|
+
data: { ...connection2, settings },
|
|
4067
|
+
manifest,
|
|
4068
|
+
row: { id, type: "tag", webId: audienceDetail == null ? void 0 : audienceDetail.web_id },
|
|
4069
|
+
segment
|
|
4070
|
+
})
|
|
4071
|
+
};
|
|
4072
|
+
},
|
|
4073
|
+
// THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
|
|
4074
|
+
// whole pair exists to stop — every member would keep a label for a
|
|
4075
|
+
// segment that no longer exists.
|
|
4076
|
+
remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
|
|
4077
|
+
const segment = context == null ? void 0 : context.segment;
|
|
4078
|
+
const existing = segmentRowFor({ connection: connection2, segment });
|
|
4079
|
+
if (!(existing == null ? void 0 : existing.id)) return { message: "Mailchimp was never carrying this segment.", skipped: true };
|
|
4080
|
+
try {
|
|
4081
|
+
await api3("/lists/" + (settings == null ? void 0 : settings.audience) + "/segments/" + existing.id, {
|
|
4082
|
+
dc: settings == null ? void 0 : settings.dc,
|
|
4083
|
+
fetcher,
|
|
4084
|
+
method: "DELETE",
|
|
4085
|
+
token
|
|
4086
|
+
});
|
|
4087
|
+
} catch (error) {
|
|
4088
|
+
if (error.status !== 404) throw error;
|
|
4089
|
+
}
|
|
4090
|
+
return {
|
|
4091
|
+
message: "Mailchimp is no longer carrying this segment.",
|
|
4092
|
+
writes: segmentRowRemoveWrites({ connection: connection2, segment })
|
|
4093
|
+
};
|
|
4094
|
+
},
|
|
4095
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
4096
|
+
sync: false
|
|
4097
|
+
},
|
|
4098
|
+
// Declined for the same reason as `email` above: Drawbridge sends its own
|
|
4099
|
+
// notification SMS, and a vendor answering this would be a second sender.
|
|
3533
4100
|
sms: false,
|
|
3534
4101
|
inbound: false,
|
|
3535
4102
|
lifecycle: false,
|
|
@@ -3592,6 +4159,16 @@ var mailchimp_default2 = {
|
|
|
3592
4159
|
"MAILCHIMP_OAUTH_CLIENT_ID",
|
|
3593
4160
|
"MAILCHIMP_OAUTH_CLIENT_SECRET"
|
|
3594
4161
|
],
|
|
4162
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
4163
|
+
review: {
|
|
4164
|
+
api: "https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/",
|
|
4165
|
+
dashboard: "https://mailchimp.com/help/manage-tags/",
|
|
4166
|
+
// NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
|
|
4167
|
+
// account-wide — so there is nothing to request and nothing to re-consent.
|
|
4168
|
+
scopes: false,
|
|
4169
|
+
content: "2026-09-11",
|
|
4170
|
+
verified: null
|
|
4171
|
+
},
|
|
3595
4172
|
slug: "mailchimp",
|
|
3596
4173
|
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
3597
4174
|
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
@@ -3637,6 +4214,30 @@ var mailchimp_default2 = {
|
|
|
3637
4214
|
// adds this step, and what is charged when it runs.
|
|
3638
4215
|
usage: { actions: 1 }
|
|
3639
4216
|
})
|
|
4217
|
+
},
|
|
4218
|
+
segment: {
|
|
4219
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
4220
|
+
// these fire from the segment's own lifecycle, not from a workflow
|
|
4221
|
+
// somebody assembled.
|
|
4222
|
+
//
|
|
4223
|
+
// The trigger is declared HERE rather than hard-coded in drawbridge-sync,
|
|
4224
|
+
// which is what lets a vendor arrive with its own without a queue edit.
|
|
4225
|
+
register: () => ({
|
|
4226
|
+
description: "Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.",
|
|
4227
|
+
hook: "segment.register",
|
|
4228
|
+
key: "Mailchimp Segment Register",
|
|
4229
|
+
queue: "connection",
|
|
4230
|
+
system: true,
|
|
4231
|
+
trigger: { event: "segment.register", type: "event" }
|
|
4232
|
+
}),
|
|
4233
|
+
remove: () => ({
|
|
4234
|
+
description: "Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.",
|
|
4235
|
+
hook: "segment.remove",
|
|
4236
|
+
key: "Mailchimp Segment Remove",
|
|
4237
|
+
queue: "connection",
|
|
4238
|
+
system: true,
|
|
4239
|
+
trigger: { event: "segment.remove", type: "event" }
|
|
4240
|
+
})
|
|
3640
4241
|
}
|
|
3641
4242
|
},
|
|
3642
4243
|
// WHY, in the merchant's words, and what to do about it.
|
|
@@ -3653,11 +4254,27 @@ var mailchimp_default2 = {
|
|
|
3653
4254
|
}
|
|
3654
4255
|
];
|
|
3655
4256
|
},
|
|
3656
|
-
title: "Mailchimp"
|
|
4257
|
+
title: "Mailchimp",
|
|
4258
|
+
// THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
|
|
4259
|
+
// the web_id field is "The ID used in the Mailchimp web application. View this
|
|
4260
|
+
// list in your Mailchimp account at
|
|
4261
|
+
// https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
|
|
4262
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
|
|
4263
|
+
// 2026-09-11).
|
|
4264
|
+
//
|
|
4265
|
+
// It lands on the audience's contacts, where the Drawbridge tag is one filter
|
|
4266
|
+
// away. Mailchimp documents no url that pre-selects a tag, so this stops one
|
|
4267
|
+
// click short rather than guessing at one that could break silently.
|
|
4268
|
+
urls: {
|
|
4269
|
+
segment: (row2, data2) => {
|
|
4270
|
+
var _a;
|
|
4271
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.dc) && (row2 == null ? void 0 : row2.webId) ? "https://" + data2.settings.dc + ".admin.mailchimp.com/lists/members/?id=" + row2.webId : null;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
3657
4274
|
};
|
|
3658
4275
|
|
|
3659
4276
|
// lib/connections/providers/shopify.js
|
|
3660
|
-
var
|
|
4277
|
+
var import_node_crypto6 = require("crypto");
|
|
3661
4278
|
var import_nanoid3 = require("nanoid");
|
|
3662
4279
|
|
|
3663
4280
|
// lib/connections/icons/shopify.js
|
|
@@ -3773,6 +4390,28 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
|
|
|
3773
4390
|
{ attrMap: {}, attributedGross: 0, attributedLines: [] }
|
|
3774
4391
|
);
|
|
3775
4392
|
var generateDiscountCode = (0, import_nanoid3.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
|
|
4393
|
+
var blockedReason = (discount) => {
|
|
4394
|
+
var _a;
|
|
4395
|
+
if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
|
|
4396
|
+
const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
|
|
4397
|
+
if (buyers && buyers !== "DiscountBuyerSelectionAll") {
|
|
4398
|
+
return "This discount is limited to specific buyers in Shopify, so a code issued to an entrant won't work. Set it to all customers to use it here.";
|
|
4399
|
+
}
|
|
4400
|
+
;
|
|
4401
|
+
if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
|
|
4402
|
+
return "This discount has reached its total usage limit.";
|
|
4403
|
+
}
|
|
4404
|
+
;
|
|
4405
|
+
return null;
|
|
4406
|
+
};
|
|
4407
|
+
var discountWarning = (discount) => {
|
|
4408
|
+
if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
|
|
4409
|
+
return "This discount hasn't started yet, so codes issued before it does won't work until then.";
|
|
4410
|
+
}
|
|
4411
|
+
;
|
|
4412
|
+
if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
|
|
4413
|
+
return null;
|
|
4414
|
+
};
|
|
3776
4415
|
var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
|
|
3777
4416
|
var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
|
|
3778
4417
|
var OAUTH_ERROR_SOURCE = "oauth";
|
|
@@ -3820,7 +4459,7 @@ var shopify_default2 = {
|
|
|
3820
4459
|
description: [
|
|
3821
4460
|
"Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.",
|
|
3822
4461
|
"Drawbridge attributes orders that originate from your campaigns \u2014 matched through cart parameters and lead-mapped discount codes \u2014 so you can see the revenue each campaign drives.",
|
|
3823
|
-
"
|
|
4462
|
+
"Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app's own webhooks, which Shopify applies when the app is installed."
|
|
3824
4463
|
],
|
|
3825
4464
|
errors: {
|
|
3826
4465
|
connect: {
|
|
@@ -3834,7 +4473,7 @@ var shopify_default2 = {
|
|
|
3834
4473
|
"Open the Drawbridge listing on the Shopify App Store.",
|
|
3835
4474
|
"Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
|
|
3836
4475
|
"Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
|
|
3837
|
-
"Come back here \u2014 the connections list updates on its own once the install
|
|
4476
|
+
"Come back here \u2014 the connections list updates on its own once the install finishes."
|
|
3838
4477
|
],
|
|
3839
4478
|
// Names where the link GOES rather than what it does: installing happens on
|
|
3840
4479
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
@@ -4210,9 +4849,11 @@ var shopify_default2 = {
|
|
|
4210
4849
|
phone: customerPhone
|
|
4211
4850
|
} : null;
|
|
4212
4851
|
const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
|
|
4213
|
-
const
|
|
4852
|
+
const createsOrder = !backfill && (isConversion || Boolean(discount));
|
|
4853
|
+
const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (createsOrder ? mintId() : null);
|
|
4854
|
+
const redemptionDocId = discount ? mintId() : null;
|
|
4214
4855
|
const writes = [];
|
|
4215
|
-
if (
|
|
4856
|
+
if (createsOrder) {
|
|
4216
4857
|
writes.push({
|
|
4217
4858
|
collection: "order",
|
|
4218
4859
|
data: {
|
|
@@ -4233,15 +4874,25 @@ var shopify_default2 = {
|
|
|
4233
4874
|
provider: { id: String(orderId), slug: "shopify" },
|
|
4234
4875
|
purchasedAt,
|
|
4235
4876
|
rate,
|
|
4877
|
+
// Null on a conversion that matched no code of ours; the
|
|
4878
|
+
// backfill branch below sets it when one arrives later.
|
|
4879
|
+
redemption: redemptionDocId,
|
|
4236
4880
|
source,
|
|
4237
|
-
status: "completed"
|
|
4881
|
+
status: "completed",
|
|
4882
|
+
type: isConversion ? "conversion" : "redemption"
|
|
4238
4883
|
},
|
|
4239
4884
|
operation: "create"
|
|
4240
4885
|
});
|
|
4241
4886
|
if (org == null ? void 0 : org.usage) {
|
|
4242
4887
|
writes.push({
|
|
4243
4888
|
collection: "usage",
|
|
4244
|
-
|
|
4889
|
+
// TWO METERS, NOT ONE SUMMED. `revenue` has always meant
|
|
4890
|
+
// conversion revenue and is the figure the fee is charged
|
|
4891
|
+
// against, so redemption money gets its own key rather than
|
|
4892
|
+
// changing what an existing number means.
|
|
4893
|
+
data: {
|
|
4894
|
+
$inc: isConversion ? { "totals.revenue": gross } : { "totals.redemptionRevenue": gross }
|
|
4895
|
+
},
|
|
4245
4896
|
operation: "update",
|
|
4246
4897
|
query: { id: org.usage }
|
|
4247
4898
|
});
|
|
@@ -4249,7 +4900,12 @@ var shopify_default2 = {
|
|
|
4249
4900
|
if (leadId) {
|
|
4250
4901
|
writes.push({
|
|
4251
4902
|
collection: "lead",
|
|
4252
|
-
|
|
4903
|
+
// Same grouped shape the contact carries, so a lead and the
|
|
4904
|
+
// contact built from it cannot be read two different ways.
|
|
4905
|
+
data: { $inc: {
|
|
4906
|
+
"totals.orders.total": 1,
|
|
4907
|
+
...isConversion ? { "totals.orders.conversions": 1 } : { "totals.orders.redemptions": 1 }
|
|
4908
|
+
} },
|
|
4253
4909
|
operation: "update",
|
|
4254
4910
|
options: { bypassDocumentValidation: true },
|
|
4255
4911
|
query: { id: leadId }
|
|
@@ -4268,6 +4924,7 @@ var shopify_default2 = {
|
|
|
4268
4924
|
customer,
|
|
4269
4925
|
discount,
|
|
4270
4926
|
gross,
|
|
4927
|
+
id: redemptionDocId,
|
|
4271
4928
|
lead: leadId,
|
|
4272
4929
|
order: orderDocId,
|
|
4273
4930
|
organization: campaignOrganization,
|
|
@@ -4296,6 +4953,14 @@ var shopify_default2 = {
|
|
|
4296
4953
|
query: { id: leadId }
|
|
4297
4954
|
});
|
|
4298
4955
|
}
|
|
4956
|
+
if (backfill && orderDocId && redemptionDocId) {
|
|
4957
|
+
writes.push({
|
|
4958
|
+
collection: "order",
|
|
4959
|
+
data: { $set: { redemption: redemptionDocId } },
|
|
4960
|
+
operation: "update",
|
|
4961
|
+
query: { id: orderDocId }
|
|
4962
|
+
});
|
|
4963
|
+
}
|
|
4299
4964
|
}
|
|
4300
4965
|
const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
|
|
4301
4966
|
const providerRow2 = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
|
|
@@ -4674,7 +5339,7 @@ var shopify_default2 = {
|
|
|
4674
5339
|
event: "shopify.register.webhooks"
|
|
4675
5340
|
},
|
|
4676
5341
|
name: "register",
|
|
4677
|
-
options: { jobId: "connection.update.register." + workflow.connection + "." + (0,
|
|
5342
|
+
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
|
|
4678
5343
|
queue: "connection"
|
|
4679
5344
|
}],
|
|
4680
5345
|
message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
|
|
@@ -4788,10 +5453,19 @@ var shopify_default2 = {
|
|
|
4788
5453
|
// picker stores, which is why the tail is taken here rather than by
|
|
4789
5454
|
// each caller that happened to remember.
|
|
4790
5455
|
items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
|
|
4791
|
-
var _a2, _b2
|
|
5456
|
+
var _a2, _b2;
|
|
5457
|
+
const node = ((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.codeDiscount) || {};
|
|
4792
5458
|
return {
|
|
4793
|
-
|
|
4794
|
-
|
|
5459
|
+
// Null when the discount can be used, a sentence when it cannot.
|
|
5460
|
+
// The picker greys the row and shows this instead of hiding it:
|
|
5461
|
+
// a discount the merchant can see in Shopify admin, missing here
|
|
5462
|
+
// with no explanation, reads as a bug in us.
|
|
5463
|
+
blocked: blockedReason(node),
|
|
5464
|
+
id: String(((_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.id) || "").split("/").pop(),
|
|
5465
|
+
// Usable, but not in the way the merchant probably expects.
|
|
5466
|
+
// Shown beside the row without stopping them.
|
|
5467
|
+
warning: discountWarning(node),
|
|
5468
|
+
title: node.title
|
|
4795
5469
|
};
|
|
4796
5470
|
}),
|
|
4797
5471
|
pageInfo: {
|
|
@@ -4806,20 +5480,6 @@ var shopify_default2 = {
|
|
|
4806
5480
|
},
|
|
4807
5481
|
icon: shopify_default,
|
|
4808
5482
|
inbound,
|
|
4809
|
-
// THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
|
|
4810
|
-
//
|
|
4811
|
-
// Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
|
|
4812
|
-
// in the shared resolver — a hardcoded vendor branch in code every vendor runs
|
|
4813
|
-
// through, which is the arrangement these manifests exist to remove.
|
|
4814
|
-
//
|
|
4815
|
-
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
4816
|
-
// connected connection. The app handle is NAMED by `requires` and read from
|
|
4817
|
-
// the env the resolver passes, never from process.env here.
|
|
4818
|
-
manage: (data2, env) => {
|
|
4819
|
-
var _a;
|
|
4820
|
-
const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
|
|
4821
|
-
return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
|
|
4822
|
-
},
|
|
4823
5483
|
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
4824
5484
|
// what an admin types on the provider screen. The four names below are exactly
|
|
4825
5485
|
// what `requires` gates on, which is the point of declaring them together: a
|
|
@@ -4865,6 +5525,14 @@ var shopify_default2 = {
|
|
|
4865
5525
|
"SHOPIFY_APP_LISTING_URL",
|
|
4866
5526
|
"SHOPIFY_APP_HANDLE"
|
|
4867
5527
|
],
|
|
5528
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
5529
|
+
review: {
|
|
5530
|
+
api: "https://shopify.dev/docs/api/admin-graphql",
|
|
5531
|
+
dashboard: "https://help.shopify.com/en/manual/apps",
|
|
5532
|
+
scopes: "https://shopify.dev/docs/api/usage/access-scopes",
|
|
5533
|
+
content: "2026-09-11",
|
|
5534
|
+
verified: null
|
|
5535
|
+
},
|
|
4868
5536
|
slug: "shopify",
|
|
4869
5537
|
// The install is the whole configuration — Shopify hands back the shop and
|
|
4870
5538
|
// there is nothing further to choose. `shop` absent means the install did not
|
|
@@ -4952,8 +5620,11 @@ var shopify_default2 = {
|
|
|
4952
5620
|
})
|
|
4953
5621
|
},
|
|
4954
5622
|
// SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
|
|
4955
|
-
// in the builder, so they carry no
|
|
4956
|
-
//
|
|
5623
|
+
// in the builder, so they carry no usage. These two are fired by a webhook
|
|
5624
|
+
// arriving rather than by a workflow trigger, so they name none either —
|
|
5625
|
+
// and naming none is what stops a workflow being provisioned for them.
|
|
5626
|
+
// Declared because the routing table and the system-workflow descriptions
|
|
5627
|
+
// both read here.
|
|
4957
5628
|
order: {
|
|
4958
5629
|
record: () => ({
|
|
4959
5630
|
description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
|
|
@@ -4985,7 +5656,8 @@ var shopify_default2 = {
|
|
|
4985
5656
|
hook: "lifecycle.health",
|
|
4986
5657
|
key: "Shopify Connection Health",
|
|
4987
5658
|
queue: "connection",
|
|
4988
|
-
system: true
|
|
5659
|
+
system: true,
|
|
5660
|
+
trigger: { event: "day", type: "schedule" }
|
|
4989
5661
|
})
|
|
4990
5662
|
},
|
|
4991
5663
|
// Audit-only. The "Shopify Token Activity" system workflow lists these
|
|
@@ -5046,11 +5718,34 @@ var shopify_default2 = {
|
|
|
5046
5718
|
] : []
|
|
5047
5719
|
];
|
|
5048
5720
|
},
|
|
5049
|
-
title: "Shopify"
|
|
5721
|
+
title: "Shopify",
|
|
5722
|
+
// THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
|
|
5723
|
+
// here rather than at the top level so a second link (a product, an order)
|
|
5724
|
+
// is a key in this object instead of a new manifest key nobody agreed on.
|
|
5725
|
+
//
|
|
5726
|
+
// AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
|
|
5727
|
+
// {...}` in the shared resolver — a hardcoded vendor branch in code every
|
|
5728
|
+
// vendor runs through, which is the arrangement these manifests exist to
|
|
5729
|
+
// remove.
|
|
5730
|
+
//
|
|
5731
|
+
// Never projected: the api composes connect.manage from it, and
|
|
5732
|
+
// resolveConnection drops the object, because a url built from settings is
|
|
5733
|
+
// built where the settings are already decrypted.
|
|
5734
|
+
urls: {
|
|
5735
|
+
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
5736
|
+
// connected connection. The app handle is NAMED by `requires` and read from
|
|
5737
|
+
// the env its caller passes — the api's resolve() hands it the stored
|
|
5738
|
+
// credentials, never process.env.
|
|
5739
|
+
manage: (data2, env) => {
|
|
5740
|
+
var _a;
|
|
5741
|
+
const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
|
|
5742
|
+
return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5050
5745
|
};
|
|
5051
5746
|
|
|
5052
5747
|
// lib/connections/providers/webhook.js
|
|
5053
|
-
var
|
|
5748
|
+
var import_node_crypto7 = __toESM(require("crypto"), 1);
|
|
5054
5749
|
|
|
5055
5750
|
// lib/safe-http.js
|
|
5056
5751
|
var import_dns2 = __toESM(require("dns"), 1);
|
|
@@ -5204,7 +5899,7 @@ var signature = ({ body, settings }) => {
|
|
|
5204
5899
|
return [
|
|
5205
5900
|
"t=" + timestamp,
|
|
5206
5901
|
...[settings.secret, ...previous].map(
|
|
5207
|
-
(secret) => "v1=" +
|
|
5902
|
+
(secret) => "v1=" + import_node_crypto7.default.createHmac("sha256", secret).update(payload).digest("hex")
|
|
5208
5903
|
)
|
|
5209
5904
|
].join(",");
|
|
5210
5905
|
};
|
|
@@ -5226,7 +5921,7 @@ var webhook_default = {
|
|
|
5226
5921
|
content: {
|
|
5227
5922
|
confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
|
|
5228
5923
|
description: [
|
|
5229
|
-
"Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react
|
|
5924
|
+
"Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
|
|
5230
5925
|
"Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
|
|
5231
5926
|
],
|
|
5232
5927
|
excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
|
|
@@ -5327,6 +6022,9 @@ var webhook_default = {
|
|
|
5327
6022
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
5328
6023
|
// stored safely, so the connection must not be offered at all.
|
|
5329
6024
|
requires: ["ENCRYPT_CONNECTION_SECRET"],
|
|
6025
|
+
// NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
|
|
6026
|
+
// to link to and no scope to request: connecting mints a secret.
|
|
6027
|
+
review: false,
|
|
5330
6028
|
// Outbound only. inbound.* is false because the direction is the point: we
|
|
5331
6029
|
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
5332
6030
|
// false follows from there being no third party to authenticate against —
|
|
@@ -5633,7 +6331,7 @@ var mergeSettings = ({ existing, incoming }) => {
|
|
|
5633
6331
|
var publicConnectionKeys = Object.freeze([
|
|
5634
6332
|
"actions",
|
|
5635
6333
|
// API-COMPOSED, not manifest-declared: the api's resolve() builds it from
|
|
5636
|
-
// auth.type, content.redirect and the manifest's manage() — the client reads
|
|
6334
|
+
// auth.type, content.redirect and the manifest's urls.manage() — the client reads
|
|
5637
6335
|
// connect.type to choose entered-vs-installed, connect.redirect for the App
|
|
5638
6336
|
// Store link, connect.manage for the admin deep link. It was dropped from
|
|
5639
6337
|
// this list when the manifests stopped declaring it, which stripped the
|
|
@@ -5692,20 +6390,20 @@ var clearProviderMemo = () => providerMemo.clear();
|
|
|
5692
6390
|
var providerRow = async ({ controller, slug: slug2 }) => {
|
|
5693
6391
|
const memoized = providerMemo.get(slug2);
|
|
5694
6392
|
if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
|
|
5695
|
-
const
|
|
6393
|
+
const row2 = await controller.get({
|
|
5696
6394
|
collection: "provider",
|
|
5697
6395
|
query: { slug: slug2 }
|
|
5698
6396
|
});
|
|
5699
6397
|
const value = {
|
|
5700
|
-
enabled: (
|
|
5701
|
-
settings: (
|
|
6398
|
+
enabled: (row2 == null ? void 0 : row2.enabled) !== false,
|
|
6399
|
+
settings: (row2 == null ? void 0 : row2.settings) ? decrypt(row2.settings) : {}
|
|
5702
6400
|
};
|
|
5703
6401
|
providerMemo.set(slug2, { at: Date.now(), value });
|
|
5704
6402
|
return value;
|
|
5705
6403
|
};
|
|
5706
6404
|
var providerSettings = async ({ controller, includeDisabled = false, slug: slug2 }) => {
|
|
5707
|
-
const
|
|
5708
|
-
return
|
|
6405
|
+
const row2 = await providerRow({ controller, slug: slug2 });
|
|
6406
|
+
return row2.enabled || includeDisabled ? row2.settings : {};
|
|
5709
6407
|
};
|
|
5710
6408
|
var vendorEnabled = async ({ controller, vendor }) => (await providerRow({ controller, slug: vendor })).enabled;
|
|
5711
6409
|
var vendorSettings = async ({ controller, slug: slug2 }) => {
|