@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.
@@ -1,18 +1,18 @@
1
1
  import { authToken } from './oauth.js';
2
2
  export { consentUrl, pkcePair } from './oauth.js';
3
+ import { toE164, detectCountry } from '../phone.js';
3
4
  import { request } from '../http.js';
4
5
  import { channels } from '../pricing.js';
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.js';
8
- import { toE164 } from '../phone.js';
9
9
  import { conversionRate } from '../plans.js';
10
10
  import { safeRequest } from '../safe-http.js';
11
+ import 'libphonenumber-js';
11
12
  import '../billing.js';
12
13
  import '../transactions.js';
13
14
  import '@drawbridge/drawbridge-telemetry';
14
15
  import '../usage.js';
15
- import 'libphonenumber-js';
16
16
  import '../features.js';
17
17
  import '../index.js';
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 (historically
614
- // env var names, kept as the stable vocabulary)
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 is becoming the way your Drawbridge contacts sync into an Attentive segment.',
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
- 'Subscriber syncing is not live yet, so connecting today does nothing except choose the segment it will use when it ships.'
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
- // there is nothing for them to do. The exchange already yields the
906
- // tokens and Attentive documents no account-identity endpoint to
907
- // enrich them with, so connect has nothing to add; and they document
908
- // no revocation endpoint at all, so disconnect has nothing to call.
909
- // Recorded as a decision rather than left as an unkept promise.
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
- contacts : { remove : false, sync : false },
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 = fetch } = {} ) => {
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 response = await fetcher(
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
- // when it ships, needs somewhere to put people.
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
- // No steps: subscriber sync has not shipped, so this vendor contributes
1006
- // nothing to a workflow yet. An empty steps object is the honest declaration.
1007
- steps : {},
1008
- tasks : ( data ) => [
1009
- ...( data?.settings?.segment
1010
- ? []
1011
- : [
1012
- {
1013
- message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
1014
- title : 'Choose a segment'
1015
- }
1016
- ]
1017
- ),
1018
- {
1019
- message : 'Contact syncing to Attentive segments has not shipped yet. Nothing is being sent to Attentive right now.',
1020
- title : 'Subscriber sync not available yet',
1021
- type : 'warning'
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.001 127.523L323.693 108.282L284.948 178.498V321.596L322.923 391.749L359.393 372.79L326.224 311.52V188.73L360.001 127.523Z" fill="#0D1314"/>
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="white"/>
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
- tasks : ( data ) => ( data?.settings?.list
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 Networking in your organization settings puts your own brand in the from line.',
2786
- 'This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so connecting today does nothing except choose the audience it will use when it ships.'
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
- contacts : { remove : false, sync : false },
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 = fetch } = {} ) => {
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 response = await fetcher(
2904
- base( dc ) + '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
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 key with no audience chosen is authenticated and inert. Mailchimp also
2965
- // needs its merge fields created on that audience before any Drawbridge total
2966
- // can be written to a member unlike Klaviyo, its custom fields are not
2967
- // schemaless so the audience must be picked before lifecycle.register has
2968
- // anything to register against.
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
- // No steps: audience sync has not shipped, so this vendor contributes nothing
2971
- // to a workflow yet. An empty steps object is the honest declaration — the
2972
- // catalog renders the connection, and no builder offers a step it cannot run.
2973
- steps : {},
2974
- tasks : ( data ) => [
2975
- ...( data?.settings?.audience
2976
- ? []
2977
- : [
2978
- {
2979
- message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
2980
- title : 'Choose an audience'
2981
- }
2982
- ]
2983
- ),
2984
- {
2985
- message : 'Contact syncing to Mailchimp audiences has not shipped yet, and this connection no longer sends your email. Nothing is being sent to Mailchimp right now.',
2986
- title : 'Audience sync not available yet',
2987
- type : 'warning'
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="white"/>
3001
- <path fill-rule="evenodd" clip-rule="evenodd" d="M309.524 407.192L308.799 128.423C306.921 126.545 303.258 127.112 301.827 127.531L292.29 130.487C291.113 126.613 289.585 122.854 287.726 119.258C280.959 106.337 271.069 99.5052 259.096 99.4866H259.059C258.259 99.4866 257.469 99.5609 256.67 99.626L256.577 99.6353C256.231 99.2089 255.871 98.7935 255.499 98.3897C250.293 92.8125 243.601 90.0889 235.588 90.3213C220.139 90.7675 204.755 101.941 192.271 121.786C183.487 135.757 176.823 153.298 174.917 166.878L144.493 176.313C135.542 179.129 135.263 179.408 134.082 187.858C133.199 194.253 109.766 375.69 109.766 375.69L306.159 409.692L309.524 407.192ZM245.181 103.065C242.569 101.346 239.511 100.546 235.885 100.621C212.033 101.308 191.23 138.611 185.923 163.467L208.771 156.384L212.851 155.119C215.845 139.336 223.355 122.957 233.181 112.416C236.616 108.639 240.671 105.477 245.172 103.065H245.181ZM224.145 151.615L256.94 141.446C257.042 132.894 256.112 120.252 251.836 111.329C247.282 113.207 243.452 116.497 240.7 119.444C233.329 127.373 227.315 139.466 224.155 151.615H224.145ZM267.211 138.267L282.455 133.536C280.02 125.616 274.238 112.342 262.517 110.111C266.161 119.527 267.099 130.431 267.211 138.267Z" fill="#95BF47"/>
3002
- <path d="M353.528 149.156C352.356 149.063 329.657 148.709 329.657 148.709C329.657 148.709 310.666 130.249 308.789 128.362C308.062 127.691 307.141 127.268 306.158 127.153V409.64L391.257 388.456C391.257 388.456 356.53 153.366 356.307 151.758C356.199 151.075 355.866 150.448 355.361 149.976C354.856 149.505 354.216 149.216 353.528 149.156Z" fill="#5E8E3E"/>
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
- env : 'SHOPIFY_APP_LISTING_URL',
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
- tasks : ( { settings } ) => ( settings?.secret
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 step type belongs to exactly one vendor.
5516
+ // A STEP TYPE MAY BE SHARED, BUT NOT ITS QUEUE.
5041
5517
  //
5042
- // This is checked because the current naming HIDES the problem rather than
5043
- // solving it: every commerce step is namespaced by vendor
5044
- // (step.shopify.customer.insert), so a collision is impossible only for as long
5045
- // as that holds. The moment a capability is named for what it does rather than
5046
- // who does it — step.commerce.customer.insert, which is where this should go —
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
- // Fail at import instead. When the neutral names land, this is the guard that
5051
- // makes the ambiguity visible on the first line of the first test run.
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 owners = {};
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
- if( owners[ type ] ){
5064
-
5065
- throw new Error( 'Step ' + type + ' is declared by both ' + owners[ type ] + ' and ' + slug );
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
- owners[ type ] = slug;
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
- .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
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 )