@drawbridge/drawbridge-utils 0.0.121 → 0.0.125
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 +465 -130
- package/dist/connections/index.d.cts +626 -141
- package/dist/connections/index.d.ts +626 -141
- package/dist/connections/index.js +458 -123
- package/dist/providers.cjs +470 -167
- package/dist/providers.d.cts +40 -60
- package/dist/providers.d.ts +40 -60
- package/dist/providers.js +463 -160
- package/package.json +1 -1
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { authToken } from './oauth.cjs';
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.cjs';
|
|
3
|
+
import { toE164, detectCountry } from '../phone.cjs';
|
|
3
4
|
import { request } from '../http.cjs';
|
|
4
5
|
import { channels } from '../pricing.cjs';
|
|
5
|
-
import crypto, { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
6
|
+
import crypto, { createHash, createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
6
7
|
import { customAlphabet } from 'nanoid';
|
|
7
8
|
import { toCanonicalEmail } from '../email.cjs';
|
|
8
|
-
import { toE164 } from '../phone.cjs';
|
|
9
9
|
import { conversionRate } from '../plans.cjs';
|
|
10
10
|
import { safeRequest } from '../safe-http.cjs';
|
|
11
|
+
import 'libphonenumber-js';
|
|
11
12
|
import '../billing.cjs';
|
|
12
13
|
import '../transactions.cjs';
|
|
13
14
|
import '@drawbridge/drawbridge-telemetry';
|
|
14
15
|
import '../usage.cjs';
|
|
15
|
-
import 'libphonenumber-js';
|
|
16
16
|
import '../features.cjs';
|
|
17
17
|
import '../index.cjs';
|
|
18
18
|
import 'currency-codes';
|
|
@@ -610,8 +610,8 @@ const GROUPS = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging'
|
|
|
610
610
|
// authorize the consent url
|
|
611
611
|
// token the exchange/refresh url
|
|
612
612
|
// client the NAMES of our own id and secret, never the values — keys into
|
|
613
|
-
// the credential map the provider collection answers
|
|
614
|
-
//
|
|
613
|
+
// the credential map the provider collection answers, matching a
|
|
614
|
+
// provider field's `credential`
|
|
615
615
|
// redirect our callback path — DECLARED, never derived from the slug. It is
|
|
616
616
|
// registered in the vendor's console and they refuse anything that
|
|
617
617
|
// does not byte-match, so it is a fact about someone else's records
|
|
@@ -776,6 +776,38 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
776
776
|
<path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
|
|
777
777
|
</svg>`;
|
|
778
778
|
|
|
779
|
+
// ONE REQUEST SHAPE for every Attentive call, the way Klaviyo's file has one.
|
|
780
|
+
// The path carries its own version — the segments picker is v2 and the
|
|
781
|
+
// subscription writes are v1 — because Attentive versions per resource rather
|
|
782
|
+
// than per API.
|
|
783
|
+
const api$2 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
784
|
+
|
|
785
|
+
const response = await fetcher( 'https://api.attentivemobile.com' + path, {
|
|
786
|
+
...( payload && { body : JSON.stringify( payload ) }),
|
|
787
|
+
headers : {
|
|
788
|
+
authorization : 'Bearer ' + token,
|
|
789
|
+
...( payload && { 'content-type' : 'application/json' })
|
|
790
|
+
},
|
|
791
|
+
method,
|
|
792
|
+
signal : AbortSignal.timeout( 15000 )
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
if( ! response.ok ){
|
|
796
|
+
|
|
797
|
+
throw Object.assign(
|
|
798
|
+
new Error( 'Attentive refused the request (' + response.status + ')' ),
|
|
799
|
+
{ status : response.status }
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 202 ACCEPTED may carry a body (the bulk-segment job id) or nothing at all,
|
|
805
|
+
// and asking json() for an empty body throws — which would report a write
|
|
806
|
+
// Attentive accepted as a failed step. A body that will not parse answers null.
|
|
807
|
+
return response.json().catch( () => null );
|
|
808
|
+
|
|
809
|
+
};
|
|
810
|
+
|
|
779
811
|
// Attentive — SMS-first marketing, installed as a distributed Attentive app.
|
|
780
812
|
//
|
|
781
813
|
// EVERY VENDOR FACT BELOW IS CITED from docs.attentive.com (fetched 2026-09-01):
|
|
@@ -860,9 +892,10 @@ var attentive = {
|
|
|
860
892
|
// has to say so rather than let them believe otherwise.
|
|
861
893
|
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 — neither list is deleted.',
|
|
862
894
|
description : [
|
|
863
|
-
'Attentive is where your SMS marketing lives, and this connection
|
|
895
|
+
'Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment — subscribed for marketing and added to the segment you choose.',
|
|
864
896
|
'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
|
|
865
|
-
'
|
|
897
|
+
'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.',
|
|
898
|
+
'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.'
|
|
866
899
|
],
|
|
867
900
|
excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
|
|
868
901
|
guide : [
|
|
@@ -882,7 +915,11 @@ var attentive = {
|
|
|
882
915
|
label : 'Attentive segment',
|
|
883
916
|
message : 'Contacts your campaigns collect are synced into this segment.',
|
|
884
917
|
hook : 'resources.audiences',
|
|
885
|
-
required : true
|
|
918
|
+
required : true,
|
|
919
|
+
// CONSUMED BY THE MEMBERSHIP CALL, not by the subscribe. Attentive's
|
|
920
|
+
// /v1/subscriptions takes no segment id — subscription and segment
|
|
921
|
+
// membership are two operations here — so contacts.sync makes both calls
|
|
922
|
+
// and this value is the externalId the second one carries.
|
|
886
923
|
// No `search : false` here, and that is a first: /v2/segments takes a
|
|
887
924
|
// `name` filter (partial match, cited above), so this picker searches
|
|
888
925
|
// the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
|
|
@@ -896,17 +933,25 @@ var attentive = {
|
|
|
896
933
|
hooks : {
|
|
897
934
|
|
|
898
935
|
auth : {
|
|
899
|
-
// The exchange already yields the tokens, and Attentive documents no
|
|
900
|
-
// account-identity endpoint to enrich them with — Klaviyo's connect
|
|
901
|
-
// reads the account name back; this has nothing cited to read. The
|
|
902
|
-
// callback stores the tokens and skips enrichment on `unimplemented`.
|
|
903
936
|
// FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
|
|
904
|
-
// dependencies", and nothing anywhere implements either of these —
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
937
|
+
// dependencies", and nothing anywhere implements either of these — there
|
|
938
|
+
// is nothing for them to do. They document no revocation endpoint at all,
|
|
939
|
+
// so disconnect has nothing to call. Recorded as a decision rather than
|
|
940
|
+
// left as an unkept promise.
|
|
941
|
+
//
|
|
942
|
+
// STILL FALSE AFTER LOOKING AGAIN, and this is the reason written down so
|
|
943
|
+
// nobody re-derives it. Klaviyo's connect reads the account name back so
|
|
944
|
+
// the card is not blank; Attentive's card stays blank. There IS an
|
|
945
|
+
// endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
|
|
946
|
+
// on docs.attentive.com/pages/authentication/ as returning "information
|
|
947
|
+
// specific to your company" — but its RESPONSE SCHEMA is published
|
|
948
|
+
// nowhere we can read: the docs show the curl and no body. Reading
|
|
949
|
+
// `body.name` would be a guess, and a guess here fails at the worst
|
|
950
|
+
// moment, in the callback, after the merchant has already consented.
|
|
951
|
+
//
|
|
952
|
+
// A live token settles it in one call, alongside the three registration
|
|
953
|
+
// checks in the header. Until then the honest state is a blank field, not
|
|
954
|
+
// a hopeful one.
|
|
910
955
|
connect : false,
|
|
911
956
|
disconnect : false,
|
|
912
957
|
probe : false,
|
|
@@ -931,7 +976,154 @@ var attentive = {
|
|
|
931
976
|
}
|
|
932
977
|
},
|
|
933
978
|
commerce : false,
|
|
934
|
-
|
|
979
|
+
|
|
980
|
+
// The verb the contacts.sync step points at.
|
|
981
|
+
contacts : {
|
|
982
|
+
|
|
983
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
984
|
+
// different thing from erasing the subscriber — Attentive's deletion sits
|
|
985
|
+
// behind their privacy-request API, which is a different grant.
|
|
986
|
+
remove : false,
|
|
987
|
+
|
|
988
|
+
// TWO CALLS, BECAUSE ATTENTIVE HAS TWO IDEAS.
|
|
989
|
+
//
|
|
990
|
+
// Subscribing and being in a segment are NOT the same operation here —
|
|
991
|
+
// unlike Klaviyo, where a subscription is created against the list itself.
|
|
992
|
+
// /v1/subscriptions takes no segment id at all, so the segment a merchant
|
|
993
|
+
// picked on this connection can only be honoured by the bulk segment
|
|
994
|
+
// membership API:
|
|
995
|
+
//
|
|
996
|
+
// subscribe POST /v1/subscriptions
|
|
997
|
+
// { user : { email, phone }, locale, subscriptionType } — the
|
|
998
|
+
// docs require EITHER signUpSourceId OR (locale +
|
|
999
|
+
// subscriptionType), and we hold no sign-up source. 202.
|
|
1000
|
+
//
|
|
1001
|
+
// membership POST /v2/bulk/segments/members
|
|
1002
|
+
// { externalId, members : [ { email, phone } ] }, 1-10,000
|
|
1003
|
+
// members, 202 with a batchJobId
|
|
1004
|
+
// (docs.attentive.com/reference/postbulksegmentmembers).
|
|
1005
|
+
//
|
|
1006
|
+
// unsubscribe POST /v1/subscriptions/unsubscribe
|
|
1007
|
+
// { user, subscriptions : [ { type, channel } ] }. 202.
|
|
1008
|
+
//
|
|
1009
|
+
// EVERY ONE OF THEM ANSWERS 202 ACCEPTED, which means Attentive took the
|
|
1010
|
+
// job, not that it ran — the same distinction the Shopify usage charge
|
|
1011
|
+
// makes between a 202 and a charge. The message below says accepted, and
|
|
1012
|
+
// must keep saying accepted.
|
|
1013
|
+
sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
1014
|
+
|
|
1015
|
+
const segment = settings?.segment;
|
|
1016
|
+
|
|
1017
|
+
// status() already stops a connection reaching Active without a
|
|
1018
|
+
// segment; this is the belt to that braces. A workflow saved before the
|
|
1019
|
+
// segment was chosen must not silently write into nothing.
|
|
1020
|
+
if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
|
|
1021
|
+
|
|
1022
|
+
const email = lead?.canonical?.email?.value || lead?.email;
|
|
1023
|
+
|
|
1024
|
+
// E.164 OR NOTHING. Attentive requires it, and a national-format number
|
|
1025
|
+
// is refused with the whole request — so an unparseable one is dropped
|
|
1026
|
+
// and the email carries the sync instead.
|
|
1027
|
+
const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
|
|
1028
|
+
|
|
1029
|
+
// Their `user` requires phone OR email. With neither there is nobody to
|
|
1030
|
+
// subscribe, so this is a skip rather than a failure.
|
|
1031
|
+
if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', skipped : true };
|
|
1032
|
+
|
|
1033
|
+
const user = {
|
|
1034
|
+
...( email && { email }),
|
|
1035
|
+
...( phone && { phone })
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
1039
|
+
//
|
|
1040
|
+
// Omitting them means Attentive never learns they said no, so the
|
|
1041
|
+
// merchant can import them from somewhere else and start texting them
|
|
1042
|
+
// again. Pushing them as unsubscribed makes the suppression travel with
|
|
1043
|
+
// the person, which is the reason this connection is allowed to send
|
|
1044
|
+
// anything at all.
|
|
1045
|
+
//
|
|
1046
|
+
// `suppressed` arrives as an argument because canSend() is sync's — a
|
|
1047
|
+
// manifest cannot reach it, and this rule is too important to infer.
|
|
1048
|
+
//
|
|
1049
|
+
// AND THE SEGMENT IS SKIPPED for them: adding someone who opted out to
|
|
1050
|
+
// a marketing segment is the same mistake as omitting the opt-out,
|
|
1051
|
+
// wearing the other hat.
|
|
1052
|
+
if( suppressed ){
|
|
1053
|
+
|
|
1054
|
+
await api$2( '/v1/subscriptions/unsubscribe', {
|
|
1055
|
+
fetcher,
|
|
1056
|
+
method : 'POST',
|
|
1057
|
+
payload : {
|
|
1058
|
+
// One entry per channel we can actually name them by. MARKETING
|
|
1059
|
+
// is the only type Drawbridge ever subscribed them to.
|
|
1060
|
+
subscriptions : [
|
|
1061
|
+
...( phone ? [ { channel : 'TEXT', type : 'MARKETING' } ] : [] ),
|
|
1062
|
+
...( email ? [ { channel : 'EMAIL', type : 'MARKETING' } ] : [] )
|
|
1063
|
+
],
|
|
1064
|
+
user
|
|
1065
|
+
},
|
|
1066
|
+
token
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
|
|
1071
|
+
response : { accepted : true, unsubscribed : true }
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
await api$2( '/v1/subscriptions', {
|
|
1077
|
+
fetcher,
|
|
1078
|
+
method : 'POST',
|
|
1079
|
+
payload : {
|
|
1080
|
+
// LOCALE, because we hold no signUpSourceId and the docs require
|
|
1081
|
+
// one or the other. The country is READ OFF the number when there
|
|
1082
|
+
// is one — libphonenumber knows it from the calling code — rather
|
|
1083
|
+
// than assumed; only the fallback pair below is a default, and it
|
|
1084
|
+
// is the one value here that no vendor document dictates.
|
|
1085
|
+
//
|
|
1086
|
+
// ponytail: en/US default. A `signUpSourceId` field on the
|
|
1087
|
+
// connection is the upgrade — Attentive's sign-up sources carry
|
|
1088
|
+
// the consent language, which is a better answer than any locale
|
|
1089
|
+
// we can infer — and it replaces this branch entirely.
|
|
1090
|
+
locale : {
|
|
1091
|
+
country : ( phone && detectCountry( phone ) ) || 'US',
|
|
1092
|
+
language : 'en'
|
|
1093
|
+
},
|
|
1094
|
+
subscriptionType : 'MARKETING',
|
|
1095
|
+
user
|
|
1096
|
+
},
|
|
1097
|
+
token
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// THE SEGMENT THE MERCHANT PICKED, which the subscribe above cannot
|
|
1101
|
+
// carry. One member per call rather than a batch: this hook is one
|
|
1102
|
+
// contact, and their endpoint takes 1-10,000.
|
|
1103
|
+
const membership = await api$2( '/v2/bulk/segments/members', {
|
|
1104
|
+
fetcher,
|
|
1105
|
+
method : 'POST',
|
|
1106
|
+
payload : {
|
|
1107
|
+
externalId : segment,
|
|
1108
|
+
members : [ user ]
|
|
1109
|
+
},
|
|
1110
|
+
token
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
// ACCEPTED, NOT LIVE. Both writes answered 202, which means Attentive
|
|
1115
|
+
// queued them — a merchant who reads "synced" and looks for the person
|
|
1116
|
+
// in Attentive a second later has been told the wrong thing.
|
|
1117
|
+
message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
|
|
1118
|
+
response : {
|
|
1119
|
+
accepted : true,
|
|
1120
|
+
...( membership?.batchJobId && { batchJobId : membership.batchJobId })
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
},
|
|
935
1127
|
email : false,
|
|
936
1128
|
inbound : false,
|
|
937
1129
|
lifecycle : false,
|
|
@@ -945,7 +1137,7 @@ var attentive = {
|
|
|
945
1137
|
// show a picker quietly missing most of a real account. The response's
|
|
946
1138
|
// only identifier is `externalId`, so an entry without one cannot be
|
|
947
1139
|
// stored and is dropped.
|
|
948
|
-
audiences : async ( { cursor, limit = 100, search, token }, { fetcher
|
|
1140
|
+
audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
|
|
949
1141
|
|
|
950
1142
|
const query = new URLSearchParams({
|
|
951
1143
|
limit : String( Math.min( limit, 1000 ) ),
|
|
@@ -953,24 +1145,7 @@ var attentive = {
|
|
|
953
1145
|
...( search?.value && { name : String( search.value ).trim() } )
|
|
954
1146
|
});
|
|
955
1147
|
|
|
956
|
-
const
|
|
957
|
-
'https://api.attentivemobile.com/v2/segments?' + query,
|
|
958
|
-
{
|
|
959
|
-
headers : { authorization : 'Bearer ' + token },
|
|
960
|
-
signal : AbortSignal.timeout( 15000 )
|
|
961
|
-
}
|
|
962
|
-
);
|
|
963
|
-
|
|
964
|
-
if( ! response.ok ){
|
|
965
|
-
|
|
966
|
-
throw Object.assign(
|
|
967
|
-
new Error( 'Attentive refused the request (' + response.status + ')' ),
|
|
968
|
-
{ status : response.status }
|
|
969
|
-
);
|
|
970
|
-
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const body = await response.json();
|
|
1148
|
+
const body = await api$2( '/v2/segments?' + query, { fetcher, token });
|
|
974
1149
|
|
|
975
1150
|
return {
|
|
976
1151
|
items : ( body?.segments || [] )
|
|
@@ -994,33 +1169,83 @@ var attentive = {
|
|
|
994
1169
|
|
|
995
1170
|
},
|
|
996
1171
|
icon: icon$4,
|
|
1172
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
1173
|
+
// what an admin types on the provider screen, and the only declaration of it.
|
|
1174
|
+
// It lives beside `requires`, which names the same variables: the manifest
|
|
1175
|
+
// says what it needs and this says how someone supplies it, so a credential
|
|
1176
|
+
// cannot be required by a vendor that offers nowhere to enter it.
|
|
1177
|
+
//
|
|
1178
|
+
// `redact` marks a secret — never returned by the api, and blank on save means
|
|
1179
|
+
// keep the stored value. `required` drives the live check.
|
|
1180
|
+
provider : {
|
|
1181
|
+
fields : [
|
|
1182
|
+
{ input : 'text', key : 'clientId', credential : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
1183
|
+
{ input : 'password', key : 'clientSecret', credential : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
1184
|
+
]
|
|
1185
|
+
},
|
|
997
1186
|
requires : [
|
|
998
1187
|
'ATTENTIVE_OAUTH_CLIENT_ID',
|
|
999
1188
|
'ATTENTIVE_OAUTH_CLIENT_SECRET'
|
|
1000
1189
|
],
|
|
1001
1190
|
slug : 'attentive',
|
|
1002
|
-
// A consent with no segment chosen is authenticated and inert — the sync
|
|
1003
|
-
//
|
|
1191
|
+
// A consent with no segment chosen is authenticated and inert — the sync needs
|
|
1192
|
+
// somewhere to put people — so the card says Pending rather than Active over
|
|
1193
|
+
// nothing.
|
|
1004
1194
|
status : ( data ) => ( data?.settings?.segment ? data.status : 'pending' ),
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1195
|
+
|
|
1196
|
+
steps : {
|
|
1197
|
+
|
|
1198
|
+
contacts : {
|
|
1199
|
+
|
|
1200
|
+
// A DECLARATION, not the work. The nesting IS the name: this is
|
|
1201
|
+
// `step.contacts.sync`, the string a workflow document stores. Klaviyo and
|
|
1202
|
+
// Mailchimp declare the same type — a step belongs to the capability, not
|
|
1203
|
+
// to whoever implements it — and the connection on the step document is
|
|
1204
|
+
// what says which vendor runs.
|
|
1205
|
+
sync : ({ data }) => ({
|
|
1206
|
+
|
|
1207
|
+
hook : 'contacts.sync',
|
|
1208
|
+
|
|
1209
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo: auth.connect is false
|
|
1210
|
+
// because Attentive publishes no account-identity response we can read
|
|
1211
|
+
// (see its comment), and settings.segment is an opaque externalId no
|
|
1212
|
+
// merchant would recognise in a builder label.
|
|
1213
|
+
key : 'Sync contact to Attentive',
|
|
1214
|
+
|
|
1215
|
+
queue : 'connection',
|
|
1216
|
+
|
|
1217
|
+
// Nothing for a merchant to configure on the step itself — the segment
|
|
1218
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
1219
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
1220
|
+
// stay different statements.
|
|
1221
|
+
settings : {},
|
|
1222
|
+
|
|
1223
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
1224
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
1225
|
+
// segment is the other moment a contact is worth pushing.
|
|
1226
|
+
triggers : [ 'lead.insert', 'segment.contact.add' ],
|
|
1227
|
+
|
|
1228
|
+
usage : { actions : 1 }
|
|
1229
|
+
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1022
1232
|
}
|
|
1023
|
-
|
|
1233
|
+
|
|
1234
|
+
},
|
|
1235
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
1236
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
1237
|
+
// is reconnecting — prompting "choose a segment" there asks the merchant to
|
|
1238
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
1239
|
+
// moment: the grant is good and the segment is the missing half.
|
|
1240
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.segment
|
|
1241
|
+
? []
|
|
1242
|
+
: [
|
|
1243
|
+
{
|
|
1244
|
+
message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
|
|
1245
|
+
title : 'Choose a segment'
|
|
1246
|
+
}
|
|
1247
|
+
]
|
|
1248
|
+
),
|
|
1024
1249
|
title : 'Attentive'
|
|
1025
1250
|
};
|
|
1026
1251
|
|
|
@@ -1343,7 +1568,7 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1343
1568
|
<rect width="500" height="500" fill="#BAEC5F"/>
|
|
1344
1569
|
<g clip-path="url(#clip0_2115_2832)">
|
|
1345
1570
|
<path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
|
|
1346
|
-
<path d="M360.
|
|
1571
|
+
<path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
|
|
1347
1572
|
</g>
|
|
1348
1573
|
<defs>
|
|
1349
1574
|
<clipPath id="clip0_2115_2832">
|
|
@@ -1977,6 +2202,34 @@ var drawbridge = {
|
|
|
1977
2202
|
icon: icon$3,
|
|
1978
2203
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
1979
2204
|
private : true,
|
|
2205
|
+
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
2206
|
+
// HubSpot portal. No merchant ever sees these; they are what an admin types on
|
|
2207
|
+
// the provider screen so that Drawbridge itself can send.
|
|
2208
|
+
//
|
|
2209
|
+
// They belong on THIS manifest because this is the connection that sends: the
|
|
2210
|
+
// email, sms and segment hooks below are the only things that spend them, and
|
|
2211
|
+
// a private connection is still where a vendor fact lives.
|
|
2212
|
+
//
|
|
2213
|
+
// UNLIKE every public vendor, none of these appear in `requires` — see the
|
|
2214
|
+
// comment there. Availability and configuration are different questions, and a
|
|
2215
|
+
// missing CRM token must not take every base workflow step away.
|
|
2216
|
+
provider : {
|
|
2217
|
+
fields : [
|
|
2218
|
+
{ input : 'email', key : 'accountSender', credential : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
|
|
2219
|
+
{ input : 'password', key : 'apiKey', credential : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
|
|
2220
|
+
// NOT required. The CRM sync is best-effort internal tooling and no-ops
|
|
2221
|
+
// without a token — requiring it would make the whole drawbridge provider
|
|
2222
|
+
// read not-live over something no merchant ever sees.
|
|
2223
|
+
{ input : 'password', key : 'hubspotToken', credential : 'HUBSPOT_ACCESS_TOKEN', label : 'HubSpot access token', message : 'Drawbridge\'s own CRM portal. Internal — no merchant sees this.', redact : true },
|
|
2224
|
+
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
2225
|
+
// either. Unset, it degrades to the account sender rather than
|
|
2226
|
+
// refusing to start.
|
|
2227
|
+
{ input : 'email', key : 'leadSender', credential : 'SENDGRID_SEND_FROM_ADDRESS', label : 'Lead sender', message : 'The default for lead-facing mail when a merchant has not verified their own domain.' },
|
|
2228
|
+
{ input : 'text', key : 'smsFrom', credential : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
|
|
2229
|
+
{ input : 'password', key : 'smsSid', credential : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
|
|
2230
|
+
{ input : 'password', key : 'smsToken', credential : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
|
|
2231
|
+
]
|
|
2232
|
+
},
|
|
1980
2233
|
// NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
|
|
1981
2234
|
//
|
|
1982
2235
|
// `requires` gates AVAILABILITY: a name in it that is unset removes the whole
|
|
@@ -2130,11 +2383,11 @@ var drawbridge = {
|
|
|
2130
2383
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
2131
2384
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
2132
2385
|
var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2133
|
-
<rect width="500" height="500" fill="
|
|
2386
|
+
<rect width="500" height="500" fill="#FF4B32"/>
|
|
2134
2387
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
2135
2388
|
</svg>`;
|
|
2136
2389
|
|
|
2137
|
-
const api = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
2390
|
+
const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
2138
2391
|
|
|
2139
2392
|
const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
|
|
2140
2393
|
...( payload && { body : JSON.stringify( payload ) }),
|
|
@@ -2350,7 +2603,7 @@ var klaviyo = {
|
|
|
2350
2603
|
// decided it, and asking again would be a question we can answer.
|
|
2351
2604
|
connect : async ( { tokens }, { fetcher } = {} ) => {
|
|
2352
2605
|
|
|
2353
|
-
const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
|
|
2606
|
+
const body = await api$1( '/accounts', { fetcher, token : tokens.accessToken });
|
|
2354
2607
|
|
|
2355
2608
|
const account = body?.data?.[ 0 ];
|
|
2356
2609
|
|
|
@@ -2464,7 +2717,7 @@ var klaviyo = {
|
|
|
2464
2717
|
// exist on a single-address record.
|
|
2465
2718
|
const totals = contact?.totals || {};
|
|
2466
2719
|
|
|
2467
|
-
const profile = await api( '/profiles/', {
|
|
2720
|
+
const profile = await api$1( '/profiles/', {
|
|
2468
2721
|
fetcher,
|
|
2469
2722
|
method : 'POST',
|
|
2470
2723
|
payload : {
|
|
@@ -2505,7 +2758,7 @@ var klaviyo = {
|
|
|
2505
2758
|
//
|
|
2506
2759
|
// `suppressed` arrives as an argument because canSend() is sync's —
|
|
2507
2760
|
// a manifest cannot reach it, and this rule is too important to infer.
|
|
2508
|
-
await api( '/profile-subscription-bulk-create-jobs/', {
|
|
2761
|
+
await api$1( '/profile-subscription-bulk-create-jobs/', {
|
|
2509
2762
|
fetcher,
|
|
2510
2763
|
method : 'POST',
|
|
2511
2764
|
payload : {
|
|
@@ -2586,7 +2839,7 @@ var klaviyo = {
|
|
|
2586
2839
|
|
|
2587
2840
|
while( next && audiences.length < limit && pages < 20 ){
|
|
2588
2841
|
|
|
2589
|
-
const body = await api( next, { fetcher, token });
|
|
2842
|
+
const body = await api$1( next, { fetcher, token });
|
|
2590
2843
|
|
|
2591
2844
|
for( const list of ( body?.data || [] ) ){
|
|
2592
2845
|
|
|
@@ -2633,6 +2886,16 @@ var klaviyo = {
|
|
|
2633
2886
|
|
|
2634
2887
|
},
|
|
2635
2888
|
icon: icon$2,
|
|
2889
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
2890
|
+
// what an admin types on the provider screen. Declared here rather than in a
|
|
2891
|
+
// table in lib/providers.js, so a vendor's credentials sit beside the
|
|
2892
|
+
// `requires` that names the same variables.
|
|
2893
|
+
provider : {
|
|
2894
|
+
fields : [
|
|
2895
|
+
{ input : 'text', key : 'clientId', credential : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
2896
|
+
{ input : 'password', key : 'clientSecret', credential : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
2897
|
+
]
|
|
2898
|
+
},
|
|
2636
2899
|
requires : [
|
|
2637
2900
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
2638
2901
|
'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
@@ -2700,7 +2963,11 @@ var klaviyo = {
|
|
|
2700
2963
|
|
|
2701
2964
|
},
|
|
2702
2965
|
// WHY, in the merchant's words, and what to do about it.
|
|
2703
|
-
|
|
2966
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
2967
|
+
// is reconnecting — prompting "choose a list" there asks the merchant to
|
|
2968
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
2969
|
+
// moment: the grant is good and the list is the missing half.
|
|
2970
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.list
|
|
2704
2971
|
? []
|
|
2705
2972
|
: [
|
|
2706
2973
|
{
|
|
@@ -2740,6 +3007,54 @@ const base = ( dc ) => {
|
|
|
2740
3007
|
|
|
2741
3008
|
};
|
|
2742
3009
|
|
|
3010
|
+
// ONE REQUEST SHAPE for the Marketing API, the way Klaviyo's file has one.
|
|
3011
|
+
//
|
|
3012
|
+
// Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and OAuth 2
|
|
3013
|
+
// tokens can be used to make authenticated requests the same way", so one header
|
|
3014
|
+
// serves both — the Basic form this used under keys was never OAuth-compatible.
|
|
3015
|
+
//
|
|
3016
|
+
// The host comes from base( dc ) on every call rather than being captured once:
|
|
3017
|
+
// there is no fixed host, and a request assembled without the stored data centre
|
|
3018
|
+
// is a 404 nobody can read.
|
|
3019
|
+
const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
3020
|
+
|
|
3021
|
+
const response = await fetcher( base( dc ) + path, {
|
|
3022
|
+
...( payload && { body : JSON.stringify( payload ) }),
|
|
3023
|
+
headers : {
|
|
3024
|
+
authorization : 'Bearer ' + token,
|
|
3025
|
+
...( payload && { 'content-type' : 'application/json' })
|
|
3026
|
+
},
|
|
3027
|
+
method,
|
|
3028
|
+
signal : AbortSignal.timeout( 15000 )
|
|
3029
|
+
});
|
|
3030
|
+
|
|
3031
|
+
if( ! response.ok ){
|
|
3032
|
+
|
|
3033
|
+
throw Object.assign(
|
|
3034
|
+
new Error( 'Mailchimp refused the request (' + response.status + ')' ),
|
|
3035
|
+
{ status : response.status }
|
|
3036
|
+
);
|
|
3037
|
+
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
return response.json();
|
|
3041
|
+
|
|
3042
|
+
};
|
|
3043
|
+
|
|
3044
|
+
// THE MEMBER ID IS COMPUTED, not handed back: Mailchimp addresses a member by
|
|
3045
|
+
// "the MD5 hash of the lowercase version of the list member's email address".
|
|
3046
|
+
//
|
|
3047
|
+
// That is what makes PUT an UPSERT — the same person hashes to the same id, so a
|
|
3048
|
+
// resync updates rather than creating a second member — and it is why the
|
|
3049
|
+
// address is lowercased before hashing rather than after: Ada@Example.com and
|
|
3050
|
+
// ada@example.com are one subscriber to Mailchimp and would otherwise be two.
|
|
3051
|
+
//
|
|
3052
|
+
// MD5 is the vendor's choice and nothing here is authenticated by it; it is an
|
|
3053
|
+
// address, not a secret.
|
|
3054
|
+
const subscriberHash = ( email ) => createHash( 'md5' )
|
|
3055
|
+
.update( String( email ).trim().toLowerCase() )
|
|
3056
|
+
.digest( 'hex' );
|
|
3057
|
+
|
|
2743
3058
|
// Mailchimp — contact sync, not a sender.
|
|
2744
3059
|
var mailchimp = {
|
|
2745
3060
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
@@ -2782,8 +3097,10 @@ var mailchimp = {
|
|
|
2782
3097
|
content : {
|
|
2783
3098
|
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 — neither list is deleted.',
|
|
2784
3099
|
description : [
|
|
2785
|
-
'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under
|
|
2786
|
-
'
|
|
3100
|
+
'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.',
|
|
3101
|
+
'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.',
|
|
3102
|
+
'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.',
|
|
3103
|
+
'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
|
|
2787
3104
|
],
|
|
2788
3105
|
excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
|
|
2789
3106
|
guide : [
|
|
@@ -2869,7 +3186,89 @@ var mailchimp = {
|
|
|
2869
3186
|
token : authToken
|
|
2870
3187
|
},
|
|
2871
3188
|
commerce : false,
|
|
2872
|
-
|
|
3189
|
+
|
|
3190
|
+
// The verb the contacts.sync step points at.
|
|
3191
|
+
contacts : {
|
|
3192
|
+
|
|
3193
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
3194
|
+
// different thing from deleting the member — and Mailchimp's own delete is
|
|
3195
|
+
// permanent, so the address can never be re-added.
|
|
3196
|
+
remove : false,
|
|
3197
|
+
|
|
3198
|
+
// PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
|
|
3199
|
+
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
3200
|
+
// Marketing API reference for the list-members resource.
|
|
3201
|
+
sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
3202
|
+
|
|
3203
|
+
const audience = settings?.audience;
|
|
3204
|
+
|
|
3205
|
+
// status() already stops a connection reaching Active without an
|
|
3206
|
+
// audience; this is the belt to that braces. A workflow saved before
|
|
3207
|
+
// the audience was chosen must not silently write into nothing.
|
|
3208
|
+
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
|
|
3209
|
+
|
|
3210
|
+
const email = lead?.canonical?.email?.value || lead?.email;
|
|
3211
|
+
|
|
3212
|
+
// A Mailchimp member IS an email address — there is no other identity
|
|
3213
|
+
// to write, so a lead without one is skipped rather than failed.
|
|
3214
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
3215
|
+
|
|
3216
|
+
const hash = subscriberHash( email );
|
|
3217
|
+
|
|
3218
|
+
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
3219
|
+
//
|
|
3220
|
+
// Omitting them means Mailchimp never learns they said no, so the
|
|
3221
|
+
// merchant can import them from somewhere else and start mailing them
|
|
3222
|
+
// again. Pushing them as unsubscribed makes the suppression travel with
|
|
3223
|
+
// the person, which is the reason this connection is allowed to send
|
|
3224
|
+
// anything at all.
|
|
3225
|
+
//
|
|
3226
|
+
// `suppressed` arrives as an argument because canSend() is sync's — a
|
|
3227
|
+
// manifest cannot reach it, and this rule is too important to infer.
|
|
3228
|
+
//
|
|
3229
|
+
// STATUS_IF_NEW EVERYWHERE, `status` ONLY TO SUPPRESS, and the
|
|
3230
|
+
// asymmetry is the whole point. status_if_new applies to a member being
|
|
3231
|
+
// CREATED and is ignored for one that already exists, so a resync
|
|
3232
|
+
// cannot overwrite what a subscriber themselves chose — someone who
|
|
3233
|
+
// unsubscribed inside Mailchimp stays unsubscribed. An opt-out is the
|
|
3234
|
+
// one thing allowed to overwrite, because it travels in the direction
|
|
3235
|
+
// that protects the person; so suppression sets `status` outright, and
|
|
3236
|
+
// sets status_if_new alongside it because the upsert leg still needs a
|
|
3237
|
+
// status for a member Mailchimp has never seen.
|
|
3238
|
+
const member = await api( '/lists/' + audience + '/members/' + hash, {
|
|
3239
|
+
dc : settings?.dc,
|
|
3240
|
+
fetcher,
|
|
3241
|
+
method : 'PUT',
|
|
3242
|
+
payload : {
|
|
3243
|
+
email_address : email,
|
|
3244
|
+
// FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
|
|
3245
|
+
// schemaless — a merge tag that does not exist on the audience is
|
|
3246
|
+
// refused, taking the whole request with it — and FNAME is one of
|
|
3247
|
+
// the two tags every audience is created with. The Drawbridge
|
|
3248
|
+
// totals Klaviyo receives cannot travel until something registers
|
|
3249
|
+
// merge fields on the chosen audience, which is lifecycle.register's
|
|
3250
|
+
// job and is not built.
|
|
3251
|
+
...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
|
|
3252
|
+
...( suppressed && { status : 'unsubscribed' }),
|
|
3253
|
+
status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
|
|
3254
|
+
},
|
|
3255
|
+
token
|
|
3256
|
+
});
|
|
3257
|
+
|
|
3258
|
+
return {
|
|
3259
|
+
// Merged into `context` for later steps in this run.
|
|
3260
|
+
context : { mailchimpMemberId : member?.id || hash },
|
|
3261
|
+
message : suppressed
|
|
3262
|
+
? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
|
|
3263
|
+
: 'Synced to the Mailchimp audience.',
|
|
3264
|
+
// Recorded on the run for support to read back, not a write
|
|
3265
|
+
// instruction — the hook has already written what it needed to.
|
|
3266
|
+
response : { mailchimpMemberId : member?.id || hash }
|
|
3267
|
+
};
|
|
3268
|
+
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
},
|
|
2873
3272
|
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2874
3273
|
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2875
3274
|
// these would be a second sender, which is the arrangement the platform
|
|
@@ -2889,36 +3288,16 @@ var mailchimp = {
|
|
|
2889
3288
|
// successful — the same silent truncation Klaviyo has, at a different
|
|
2890
3289
|
// number. Paged against total_items so an account past a thousand still
|
|
2891
3290
|
// resolves.
|
|
2892
|
-
audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher
|
|
2893
|
-
|
|
2894
|
-
// Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and
|
|
2895
|
-
// OAuth 2 tokens can be used to make authenticated requests the same
|
|
2896
|
-
// way", so one header serves both — the Basic form this used under
|
|
2897
|
-
// keys was never OAuth-compatible.
|
|
2898
|
-
const dc = settings?.dc;
|
|
3291
|
+
audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher } = {} ) => {
|
|
2899
3292
|
|
|
2900
3293
|
const count = Math.min( limit, 1000 );
|
|
2901
3294
|
const offset = Number( cursor || 0 );
|
|
2902
3295
|
|
|
2903
|
-
const
|
|
2904
|
-
|
|
2905
|
-
{
|
|
2906
|
-
headers : { authorization : 'Bearer ' + token },
|
|
2907
|
-
signal : AbortSignal.timeout( 15000 )
|
|
2908
|
-
}
|
|
3296
|
+
const body = await api(
|
|
3297
|
+
'/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
|
|
3298
|
+
{ dc : settings?.dc, fetcher, token }
|
|
2909
3299
|
);
|
|
2910
3300
|
|
|
2911
|
-
if( ! response.ok ){
|
|
2912
|
-
|
|
2913
|
-
throw Object.assign(
|
|
2914
|
-
new Error( 'Mailchimp refused the request (' + response.status + ')' ),
|
|
2915
|
-
{ status : response.status }
|
|
2916
|
-
);
|
|
2917
|
-
|
|
2918
|
-
}
|
|
2919
|
-
|
|
2920
|
-
const body = await response.json();
|
|
2921
|
-
|
|
2922
3301
|
const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
|
|
2923
3302
|
|
|
2924
3303
|
// Mailchimp's /lists takes no name filter, so a term is matched against
|
|
@@ -2953,6 +3332,15 @@ var mailchimp = {
|
|
|
2953
3332
|
webhook : false
|
|
2954
3333
|
},
|
|
2955
3334
|
icon: icon$1,
|
|
3335
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
3336
|
+
// what an admin types on the provider screen, beside the `requires` naming the
|
|
3337
|
+
// same variables.
|
|
3338
|
+
provider : {
|
|
3339
|
+
fields : [
|
|
3340
|
+
{ input : 'text', key : 'clientId', credential : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
3341
|
+
{ input : 'password', key : 'clientSecret', credential : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
3342
|
+
]
|
|
3343
|
+
},
|
|
2956
3344
|
// The OAuth client this deployment registered. Without both, the vendor drops
|
|
2957
3345
|
// out of availableConnections rather than offering a Connect button that
|
|
2958
3346
|
// cannot complete.
|
|
@@ -2961,32 +3349,70 @@ var mailchimp = {
|
|
|
2961
3349
|
'MAILCHIMP_OAUTH_CLIENT_SECRET'
|
|
2962
3350
|
],
|
|
2963
3351
|
slug : 'mailchimp',
|
|
2964
|
-
// A
|
|
2965
|
-
//
|
|
2966
|
-
//
|
|
2967
|
-
//
|
|
2968
|
-
//
|
|
3352
|
+
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
3353
|
+
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
3354
|
+
// nothing. Mailchimp also needs its merge fields created on that audience
|
|
3355
|
+
// before any Drawbridge total can be written to a member — unlike Klaviyo, its
|
|
3356
|
+
// custom fields are not schemaless — so the audience must be picked before
|
|
3357
|
+
// lifecycle.register has anything to register against.
|
|
2969
3358
|
status : ( data ) => ( data?.settings?.audience ? data.status : 'pending' ),
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
3359
|
+
|
|
3360
|
+
steps : {
|
|
3361
|
+
|
|
3362
|
+
contacts : {
|
|
3363
|
+
|
|
3364
|
+
// A DECLARATION, not the work. It names the hook that does the work, and
|
|
3365
|
+
// the nesting IS the name: this is `step.contacts.sync`, the string a
|
|
3366
|
+
// workflow document stores. Klaviyo and Attentive declare the same type —
|
|
3367
|
+
// a step belongs to the capability, not to whoever implements it — and the
|
|
3368
|
+
// connection on the step document is what says which vendor runs.
|
|
3369
|
+
sync : ({ data }) => ({
|
|
3370
|
+
|
|
3371
|
+
hook : 'contacts.sync',
|
|
3372
|
+
|
|
3373
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo. Mailchimp's
|
|
3374
|
+
// auth.connect deliberately stores only the data centre (a test pins
|
|
3375
|
+
// that), and settings.audience is an opaque list id no merchant would
|
|
3376
|
+
// recognise in a builder label — so the label names the vendor rather
|
|
3377
|
+
// than showing a string like a1b2c3d4e5.
|
|
3378
|
+
key : 'Sync contact to Mailchimp',
|
|
3379
|
+
|
|
3380
|
+
queue : 'connection',
|
|
3381
|
+
|
|
3382
|
+
// Nothing for a merchant to configure on the step itself — the audience
|
|
3383
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
3384
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
3385
|
+
// stay different statements.
|
|
3386
|
+
settings : {},
|
|
3387
|
+
|
|
3388
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
3389
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
3390
|
+
// segment is the other moment a contact is worth pushing.
|
|
3391
|
+
triggers : [ 'lead.insert', 'segment.contact.add' ],
|
|
3392
|
+
|
|
3393
|
+
// One source for cost: what the builder discloses before a merchant
|
|
3394
|
+
// adds this step, and what is charged when it runs.
|
|
3395
|
+
usage : { actions : 1 }
|
|
3396
|
+
|
|
3397
|
+
})
|
|
3398
|
+
|
|
2988
3399
|
}
|
|
2989
|
-
|
|
3400
|
+
|
|
3401
|
+
},
|
|
3402
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
3403
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
3404
|
+
// is reconnecting — prompting "choose a audience" there asks the merchant to
|
|
3405
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3406
|
+
// moment: the grant is good and the audience is the missing half.
|
|
3407
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.audience
|
|
3408
|
+
? []
|
|
3409
|
+
: [
|
|
3410
|
+
{
|
|
3411
|
+
message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
|
|
3412
|
+
title : 'Choose an audience'
|
|
3413
|
+
}
|
|
3414
|
+
]
|
|
3415
|
+
),
|
|
2990
3416
|
title : 'Mailchimp'
|
|
2991
3417
|
};
|
|
2992
3418
|
|
|
@@ -2997,10 +3423,9 @@ var mailchimp = {
|
|
|
2997
3423
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
2998
3424
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
2999
3425
|
var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
3000
|
-
<rect width="500" height="500" fill="
|
|
3001
|
-
<path
|
|
3002
|
-
<path d="
|
|
3003
|
-
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
3426
|
+
<rect width="500" height="500" fill="#95C049"/>
|
|
3427
|
+
<path d="M292.997 147.633C292.997 147.633 289.98 148.495 285.023 150.004C284.161 147.202 282.868 143.969 281.144 140.521C275.54 129.744 267.134 123.925 257.22 123.925C256.574 123.925 255.927 123.925 255.065 124.141C254.849 123.71 254.418 123.494 254.203 123.063C249.892 118.322 244.289 116.166 237.607 116.382C224.676 116.813 211.744 126.081 201.399 142.676C194.071 154.314 188.468 168.97 186.959 180.393C172.088 184.919 161.743 188.152 161.527 188.367C153.984 190.738 153.768 190.954 152.906 198.066C151.613 203.454 132 355.4 132 355.4L294.937 383.633V147.202C294.075 147.418 293.429 147.418 292.997 147.633ZM255.281 159.271C246.66 161.858 237.176 164.875 227.909 167.677C230.495 157.547 235.668 147.418 241.702 140.736C244.073 138.365 247.306 135.564 250.97 133.839C254.634 141.598 255.496 152.159 255.281 159.271ZM237.823 125.003C240.84 125.003 243.427 125.649 245.582 126.943C242.133 128.667 238.685 131.469 235.452 134.702C227.262 143.538 221.012 157.332 218.426 170.479C210.667 172.85 202.908 175.22 195.796 177.376C200.322 156.901 217.779 125.649 237.823 125.003ZM212.607 243.542C213.469 257.335 249.892 260.353 252.048 292.897C253.556 318.545 238.47 336.002 216.701 337.295C190.407 339.02 175.967 323.502 175.967 323.502L181.571 299.794C181.571 299.794 196.011 310.786 207.649 309.924C215.193 309.493 217.995 303.242 217.779 298.932C216.701 280.828 186.959 281.905 185.019 252.163C183.295 227.377 199.675 202.161 235.883 199.79C249.892 198.928 257.005 202.377 257.005 202.377L248.815 233.412C248.815 233.412 239.547 229.102 228.555 229.964C212.607 231.041 212.391 241.171 212.607 243.542ZM263.902 156.685C263.902 150.219 263.039 140.952 260.022 133.193C269.936 135.133 274.678 146.124 276.833 152.806C272.954 153.883 268.643 155.176 263.902 156.685Z" fill="white"/>
|
|
3428
|
+
<path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
|
|
3004
3429
|
</svg>`;
|
|
3005
3430
|
|
|
3006
3431
|
// THE SHARED HALF OF inbound.verify.
|
|
@@ -3222,7 +3647,7 @@ var shopify = {
|
|
|
3222
3647
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
3223
3648
|
// linked from inside it.
|
|
3224
3649
|
redirect : {
|
|
3225
|
-
|
|
3650
|
+
credential : 'SHOPIFY_APP_LISTING_URL',
|
|
3226
3651
|
title : 'View on the Shopify App Store'
|
|
3227
3652
|
}
|
|
3228
3653
|
},
|
|
@@ -4259,6 +4684,19 @@ var shopify = {
|
|
|
4259
4684
|
: undefined;
|
|
4260
4685
|
|
|
4261
4686
|
},
|
|
4687
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
4688
|
+
// what an admin types on the provider screen. The four names below are exactly
|
|
4689
|
+
// what `requires` gates on, which is the point of declaring them together: a
|
|
4690
|
+
// name required by the manifest and enterable nowhere is a vendor that can
|
|
4691
|
+
// never go live from the admin screen.
|
|
4692
|
+
provider : {
|
|
4693
|
+
fields : [
|
|
4694
|
+
{ input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
|
|
4695
|
+
{ input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
|
|
4696
|
+
{ input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
|
|
4697
|
+
{ input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
|
|
4698
|
+
]
|
|
4699
|
+
},
|
|
4262
4700
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
4263
4701
|
// exists and the app is fully configured. Requiring all four means it can
|
|
4264
4702
|
// never render half-configured — and absence of any one excludes the
|
|
@@ -4577,7 +5015,12 @@ var webhook = {
|
|
|
4577
5015
|
// pressing Connect will do; afterwards it states the verification the
|
|
4578
5016
|
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
4579
5017
|
// checks is an unsigned payload.
|
|
4580
|
-
|
|
5018
|
+
// The connect prompt only where connecting is not already the card's whole
|
|
5019
|
+
// story: a disconnected or errored document renders a Connect action itself,
|
|
5020
|
+
// and a task repeating it talks over the button.
|
|
5021
|
+
tasks : ( { settings, status } = {} ) => ( [ 'disconnected', 'error' ].includes( status )
|
|
5022
|
+
? []
|
|
5023
|
+
: settings?.secret
|
|
4581
5024
|
? [
|
|
4582
5025
|
{
|
|
4583
5026
|
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
|
|
@@ -4591,7 +5034,7 @@ var webhook = {
|
|
|
4591
5034
|
title : 'Webhook signing'
|
|
4592
5035
|
}
|
|
4593
5036
|
]
|
|
4594
|
-
|
|
5037
|
+
),
|
|
4595
5038
|
title : 'Webhooks'
|
|
4596
5039
|
};
|
|
4597
5040
|
|
|
@@ -4743,6 +5186,39 @@ const build = ( manifest ) => {
|
|
|
4743
5186
|
|
|
4744
5187
|
}
|
|
4745
5188
|
|
|
5189
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, if it has any. A connection
|
|
5190
|
+
// with no third party behind it — webhook — declares no block at all, which is
|
|
5191
|
+
// what keeps it off the provider screen.
|
|
5192
|
+
//
|
|
5193
|
+
// Every field is checked harder than a merchant field: it must be editable
|
|
5194
|
+
// (an admin cannot type into a read-only descriptor, and a credential nobody
|
|
5195
|
+
// can enter is a vendor that can never go live), and a `password` MUST be
|
|
5196
|
+
// redacted. That last one is a SECURITY BOUNDARY rather than tidiness —
|
|
5197
|
+
// providerFields is what the api's redaction derives from, so a password field
|
|
5198
|
+
// that forgot `redact : true` is a platform secret handed back over the wire
|
|
5199
|
+
// to every admin screen that asks.
|
|
5200
|
+
for( const field of manifest.provider?.fields || [] ){
|
|
5201
|
+
|
|
5202
|
+
if( ! field?.key || ! field?.label ){
|
|
5203
|
+
|
|
5204
|
+
throw new Error( manifest.slug + ' declares a provider field with no key or label' );
|
|
5205
|
+
|
|
5206
|
+
}
|
|
5207
|
+
|
|
5208
|
+
if( ! INPUTS.includes( field.input ) ){
|
|
5209
|
+
|
|
5210
|
+
throw new Error( manifest.slug + '.provider.' + field.key + ' needs an input the admin form can render — one of ' + INPUTS.join( ', ' ) );
|
|
5211
|
+
|
|
5212
|
+
}
|
|
5213
|
+
|
|
5214
|
+
if( field.input === 'password' && ! field.redact ){
|
|
5215
|
+
|
|
5216
|
+
throw new Error( manifest.slug + '.provider.' + field.key + ' is a password and must declare redact : true — the api would hand the value back' );
|
|
5217
|
+
|
|
5218
|
+
}
|
|
5219
|
+
|
|
5220
|
+
}
|
|
5221
|
+
|
|
4746
5222
|
// THE ICON RIDES WITH THE MANIFEST, so a vendor cannot name an asset nobody
|
|
4747
5223
|
// added — which is what the old arrangement allowed, with the markup in one
|
|
4748
5224
|
// repo and the file in another.
|
|
@@ -5037,36 +5513,41 @@ const connections = Object.freeze({
|
|
|
5037
5513
|
webhook : build( webhook )
|
|
5038
5514
|
});
|
|
5039
5515
|
|
|
5040
|
-
// A
|
|
5516
|
+
// A STEP TYPE MAY BE SHARED, BUT NOT ITS QUEUE.
|
|
5041
5517
|
//
|
|
5042
|
-
//
|
|
5043
|
-
//
|
|
5044
|
-
//
|
|
5045
|
-
//
|
|
5046
|
-
//
|
|
5047
|
-
// two vendors declaring it would silently collapse into one entry in stepQueues
|
|
5048
|
-
// and one of them would route nowhere.
|
|
5518
|
+
// A step type belongs to the CAPABILITY rather than to whoever implements it —
|
|
5519
|
+
// that is why step.shopify.* became step.commerce.*, and why Klaviyo, Mailchimp
|
|
5520
|
+
// and Attentive all declare step.contacts.sync. Sharing the name is the intent,
|
|
5521
|
+
// and the vendor is carried on the step DOCUMENT (its connection), not in the
|
|
5522
|
+
// type string.
|
|
5049
5523
|
//
|
|
5050
|
-
//
|
|
5051
|
-
//
|
|
5524
|
+
// What must not be shared is the ROUTE. stepQueues is { type : queue }, so two
|
|
5525
|
+
// vendors declaring one type with different queues collapse into a single entry
|
|
5526
|
+
// and whichever loses is enqueued nowhere — a workflow that accepts the step and
|
|
5527
|
+
// silently never runs it. That is the real failure this guard was written for;
|
|
5528
|
+
// the ownership rule was a proxy for it that also refused the case it was
|
|
5529
|
+
// designed to anticipate.
|
|
5052
5530
|
( () => {
|
|
5053
5531
|
|
|
5054
|
-
const
|
|
5532
|
+
const routes = {};
|
|
5055
5533
|
|
|
5056
5534
|
for( const [ slug, manifest ] of Object.entries( connections ) ){
|
|
5057
5535
|
|
|
5058
|
-
for( const [ name ] of leaves( manifest.steps ) ){
|
|
5536
|
+
for( const [ name, step ] of leaves( manifest.steps ) ){
|
|
5059
5537
|
|
|
5060
5538
|
const type = 'step.' + name;
|
|
5539
|
+
const queue = step({})?.queue;
|
|
5061
5540
|
|
|
5541
|
+
if( routes[ type ] && routes[ type ].queue !== queue ){
|
|
5062
5542
|
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5543
|
+
throw new Error(
|
|
5544
|
+
'Step ' + type + ' routes to ' + routes[ type ].queue + ' for ' + routes[ type ].slug
|
|
5545
|
+
+ ' and ' + queue + ' for ' + slug + ' — one of them would be enqueued nowhere'
|
|
5546
|
+
);
|
|
5066
5547
|
|
|
5067
5548
|
}
|
|
5068
5549
|
|
|
5069
|
-
|
|
5550
|
+
routes[ type ] = { queue, slug };
|
|
5070
5551
|
|
|
5071
5552
|
}
|
|
5072
5553
|
|
|
@@ -5409,7 +5890,11 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
5409
5890
|
|
|
5410
5891
|
return Object.fromEntries(
|
|
5411
5892
|
Object.entries( item )
|
|
5412
|
-
|
|
5893
|
+
// `provider` is in the list for a different reason than the rest: it is
|
|
5894
|
+
// PLATFORM configuration — which credentials an admin types in for this
|
|
5895
|
+
// vendor — and no merchant-facing resolution has any business carrying
|
|
5896
|
+
// it, even as bare descriptors.
|
|
5897
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
5413
5898
|
.map( ( [ key, value ] ) => [
|
|
5414
5899
|
key,
|
|
5415
5900
|
( typeof value === 'function' ? value( data, env ) : value )
|