@drawbridge/drawbridge-utils 0.0.118 → 0.0.124

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,20 +1,23 @@
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 } from 'node:crypto';
6
+ import crypto, { createHash, createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
7
+ import { customAlphabet } from 'nanoid';
8
+ import { toCanonicalEmail } from '../email.cjs';
9
+ import { conversionRate } from '../plans.cjs';
6
10
  import { safeRequest } from '../safe-http.cjs';
7
- import '../plans.cjs';
11
+ import 'libphonenumber-js';
12
+ import '../billing.cjs';
13
+ import '../transactions.cjs';
14
+ import '@drawbridge/drawbridge-telemetry';
15
+ import '../usage.cjs';
8
16
  import '../features.cjs';
9
17
  import '../index.cjs';
10
18
  import 'currency-codes';
11
- import 'nanoid';
12
19
  import '../color.cjs';
13
20
  import 'tinycolor2';
14
- import '../usage.cjs';
15
- import '../billing.cjs';
16
- import '../transactions.cjs';
17
- import '@drawbridge/drawbridge-telemetry';
18
21
  import 'dns';
19
22
  import 'node:http';
20
23
  import 'node:https';
@@ -228,6 +231,246 @@ const HOOKS = Object.freeze({
228
231
 
229
232
  });
230
233
 
234
+ // WHAT A HOOK RETURNS — and, just as much, what it must NOT do.
235
+ //
236
+ // A HOOK DOES THE WORK AND SAYS WHAT HAPPENED. It never records the run, never
237
+ // bills, never enqueues the next step and never decides its own price: that is
238
+ // the shell's, identically for every step. Five keys say what happened, and
239
+ // every one of them is unchanged from the day the shell was written:
240
+ //
241
+ // context merged forward into the next step's payload
242
+ // message the sentence a merchant reads in the run log
243
+ // request what was sent, for support to read back
244
+ // response what came back
245
+ // skipped true when it deliberately did no work, so it does not bill
246
+ //
247
+ // THE PROBLEM THOSE FIVE COULD NOT SOLVE. A hook that had to write a document,
248
+ // enqueue a job or tell an open dashboard something had no way to say so — so it
249
+ // did it, with a controller, a queue and a socket server handed to it. That is
250
+ // why the bodies lived in drawbridge-sync rather than beside the declarations
251
+ // that name them: a published package cannot carry a database, so any hook
252
+ // needing one had to be somewhere else, and "a vendor's logic lives in a repo
253
+ // the vendor file cannot see" is the split these manifests exist to close.
254
+ //
255
+ // So a hook DESCRIBES the side effects instead, and the shell performs them:
256
+ //
257
+ // writes [ { collection, data, ignoreDuplicate, operation, options, query } ]
258
+ // enqueues [ { data, name, options, queue } ]
259
+ // events [ { event, payload, room } ]
260
+ //
261
+ // Each is a plain array of plain objects, in the order the shell must run them,
262
+ // and each descriptor is named for the call it becomes — `operation` is the
263
+ // controller method, `queue` is the queue's own name, `room` is the socket room.
264
+ // No descriptor carries a function: a hook that could hand back a callback would
265
+ // be doing the work again with an extra step.
266
+ //
267
+ // A REJECTION MAY CARRY EFFECTS TOO. Throwing is how a hook rejects, and some
268
+ // rejections have to leave the world tidy — segment.sync locks each segment at
269
+ // `syncing` and every exit, including the failing one, has to put it back or the
270
+ // merchant watches a spinner forever. So the three keys may ride out on the
271
+ // thrown error, and the shell performs them before it records the failure. It is
272
+ // the same precedent as `error.status` and `error.response`, which the shell
273
+ // already reads off a rejection.
274
+ //
275
+ // WHAT IS STILL PASSED IN. Reads are not side effects and are not described:
276
+ // gathering the facts IS the work. A hook receives `read` — the controller's
277
+ // read-only methods and nothing else — so it cannot write through it even by
278
+ // mistake. Anything else a hook genuinely needs is injected the same way the
279
+ // `shopify` SDK is: as an argument, named for what it does.
280
+ const HOOK_EFFECTS = Object.freeze([ 'enqueues', 'events', 'writes' ]);
281
+
282
+ // The controller methods a described write may become. Only the two that a hook
283
+ // has ever needed — a deletion is a cascade, and cascades are owned by
284
+ // drawbridge-sync's streams rather than by a step.
285
+ const WRITE_OPERATIONS = Object.freeze([ 'create', 'update' ]);
286
+
287
+ // READ A HOOK'S EFFECTS, or refuse them.
288
+ //
289
+ // The shell performs whatever comes back here, so a malformed descriptor is a
290
+ // write against the wrong collection or an emit to every open dashboard. Caught
291
+ // at the boundary instead, in the hook's own words, because the alternative is a
292
+ // TypeError inside the controller naming nothing that would help.
293
+ //
294
+ // Absent keys are an EMPTY ARRAY rather than undefined: the caller performs
295
+ // `writes` unconditionally, and a shell that has to null-check three keys is
296
+ // three places one of them gets forgotten.
297
+ // WHAT A HOOK IS HANDED: TWO OBJECTS, so a signature says on sight what is a
298
+ // property of the run and what is a service being passed in.
299
+ //
300
+ // hook( props, options )
301
+ //
302
+ // PROPS are facts — data about this run, this merchant, this payload. OPTIONS
303
+ // are services — capabilities the caller injects, every one either read-only or
304
+ // owned by the shell. A hook that wants a service reaches into its second
305
+ // argument, which is what keeps a fact from quietly becoming a handle.
306
+ //
307
+ // One vocabulary for every manifest. A name below means the same thing in every
308
+ // hook that receives it; a synonym splits the vocabulary and fails the test
309
+ // that walks these.
310
+ //
311
+ // PROPS — facts of the run
312
+ // channel which inbound surface the request arrived on
313
+ // clientId, OUR app's credential VALUES, resolved by the caller from the
314
+ // clientSecret provider collection — auth.disconnect and auth.probe, where
315
+ // the vendor wants them re-presented
316
+ // connection the merchant's connection document
317
+ // contact the person's email — the address, not the lead document
318
+ // context the step's accumulated trigger data
319
+ // cursor, resources.* pagination, always these names
320
+ // limit,
321
+ // search, sort
322
+ // declaration the step declaration the shell resolved for this run
323
+ // doc the document a stream-driven hook acts on
324
+ // email, id the identifiers a contacts hook acts on when there is no doc
325
+ // event the inbound event name the manifest extracted
326
+ // headers the inbound request's headers
327
+ // lead the lead document, resolved by the shell when the run names one
328
+ // manifest the hook's own manifest, for the vendor facts it declares
329
+ // payload the verified inbound body
330
+ // scope the granted scope string being judged
331
+ // secret the credential VALUE the caller resolved for verification
332
+ // settings the connection's decrypted settings
333
+ // step the step document
334
+ // suppressed the opt-out floor's answer for this lead, computed by the shell
335
+ // token a LIVE access token, refreshed by the shell before the call —
336
+ // oauth vendors only; null everywhere else
337
+ // tokens the FRESH GRANT auth.connect receives from the oauth runner —
338
+ // the whole exchange response, not one live token
339
+ // workflow the workflow document
340
+ //
341
+ // OPTIONS — services passed in
342
+ // adminToken() mint or refresh the store token — Shopify's install
343
+ // model, where rotation persists mid-call; a resolver
344
+ // rather than a value, unlike `token`
345
+ // canSend() the opt-out floor, bound to this run's organization
346
+ // chunkSize the worker fleet's fan-out width — deployment tuning,
347
+ // passed, never published
348
+ // dispatch() the caller's own coordinator table, for hooks that are
349
+ // dispatches
350
+ // fetcher fetch-shaped transport: ( url, options )
351
+ // logger the caller's logger
352
+ // mintId() an id minted before the write it belongs to, so one
353
+ // described write can reference another
354
+ // read the controller's READ methods — get, aggregate, count —
355
+ // and never anything that writes; not `controller`, not
356
+ // `db`, because a name that implies writing invites it
357
+ // reconcileScopes() the caller's scope writer-of-record; injected because
358
+ // the hook needs its result
359
+ // request safeRequest-shaped transport: ({ body, headers, method,
360
+ // url }) — a different SHAPE than fetcher, so a different
361
+ // name
362
+ // resolveContact() the one write a description cannot carry — its result
363
+ // decides billing
364
+ // resolveSettings() fresh from wherever the vendor keeps credentials, per
365
+ // call, never a snapshot
366
+ // rotateToken() deliberate rotation, for the health check that rotates
367
+ // before the window closes
368
+ // shopify the SDK namespaces, injected — this package cannot
369
+ // import what depends on it
370
+ // THE ONE EXEMPTION: hooks.auth.token takes a SINGLE bag. It is the OAuth token
371
+ // request itself — called directly by the oauth runner and the refresh path,
372
+ // never through runHook — and its argument is the request's fields (clientId,
373
+ // code, refreshToken, fetcher) as one unit. Splitting it would change a
374
+ // wire-shaped call for symmetry's sake. Callers must never invoke it via
375
+ // runHook with a second bag: the options would be silently discarded, which is
376
+ // exactly the stubbed-fetcher-goes-live failure the split exists to prevent.
377
+ const HOOK_PROPS = Object.freeze([
378
+ 'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
379
+ 'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
380
+ 'manifest', 'payload',
381
+ 'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
382
+ 'token', 'tokens', 'workflow'
383
+ ]);
384
+
385
+ const HOOK_OPTIONS = Object.freeze([
386
+ 'adminToken', 'canSend', 'chunkSize', 'dispatch', 'fetcher', 'logger',
387
+ 'mintId', 'read', 'reconcileScopes', 'request', 'resolveContact',
388
+ 'resolveSettings', 'rotateToken', 'shopify'
389
+ ]);
390
+
391
+ const effectsOf = ( answer ) => {
392
+
393
+ for( const key of HOOK_EFFECTS ){
394
+
395
+ if( answer?.[ key ] !== undefined && ! Array.isArray( answer[ key ] ) ){
396
+
397
+ throw new Error( 'A hook returned ' + key + ' that is not an array' );
398
+
399
+ }
400
+
401
+ }
402
+
403
+ const enqueues = answer?.enqueues || [];
404
+ const events = answer?.events || [];
405
+ const writes = answer?.writes || [];
406
+
407
+ for( const write of writes ){
408
+
409
+ if( ! write?.collection ) throw new Error( 'A described write names no collection' );
410
+
411
+ if( ! WRITE_OPERATIONS.includes( write?.operation ) ){
412
+
413
+ throw new Error( 'A described write on ' + write.collection + ' needs an operation — one of ' + WRITE_OPERATIONS.join( ', ' ) );
414
+
415
+ }
416
+
417
+ if( ! write?.data ) throw new Error( 'A described write on ' + write.collection + ' carries no data' );
418
+
419
+ // AN UPDATE WITH NO QUERY IS EVERY DOCUMENT IN THE COLLECTION. Refused here
420
+ // rather than survived, because the controller would happily run it.
421
+ if( write.operation === 'update' && ! write.query ){
422
+
423
+ throw new Error( 'A described update on ' + write.collection + ' has no query — that is every document in it' );
424
+
425
+ }
426
+
427
+ // The controller's create takes no query, so one here is a filter the
428
+ // author believed in and the database never saw.
429
+ if( write.operation === 'create' && write.query ){
430
+
431
+ throw new Error( 'A described create on ' + write.collection + ' carries a query — create does not filter' );
432
+
433
+ }
434
+
435
+ }
436
+
437
+ for( const enqueue of enqueues ){
438
+
439
+ if( ! enqueue?.queue ) throw new Error( 'A described enqueue names no queue' );
440
+
441
+ // BullMQ's own first argument. Without it the job lands unnamed and no
442
+ // worker handler matches it — a job accepted and never run.
443
+ if( ! enqueue?.name ) throw new Error( 'A described enqueue on the ' + enqueue.queue + ' queue names no job' );
444
+
445
+ }
446
+
447
+ for( const event of events ){
448
+
449
+ if( ! event?.event ) throw new Error( 'A described event has no name' );
450
+
451
+ // A ROOM IS NOT OPTIONAL. An emit without one reaches every connected
452
+ // dashboard, which is one organization's data arriving in another's tab.
453
+ if( ! event?.room ) throw new Error( 'A described ' + event.event + ' event has no room' );
454
+
455
+ }
456
+
457
+ // ALL OR NONE. `transaction : true` asks the shell to land every described
458
+ // write in one transaction — for the order/redemption pair, where a
459
+ // half-written attribution is revenue counted twice or not at all. It covers
460
+ // the WRITES only: enqueues and events still run after the writes commit,
461
+ // which is the ordering a job that reads a row it expects to exist depends on.
462
+ const transaction = Boolean( answer?.transaction );
463
+
464
+ if( transaction && ! writes.length ){
465
+
466
+ throw new Error( 'A hook asked for a transaction and described no writes' );
467
+
468
+ }
469
+
470
+ return { enqueues, events, transaction, writes };
471
+
472
+ };
473
+
231
474
  // WHAT A WORKFLOW STEP CAN BE. Closed, and the LABEL LIVES HERE rather than on a
232
475
  // vendor, because a step type belongs to the capability and not to whoever
233
476
  // implements it: Klaviyo and Mailchimp both do contacts.sync, and a label taken
@@ -366,7 +609,9 @@ const GROUPS = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging'
366
609
  //
367
610
  // authorize the consent url
368
611
  // token the exchange/refresh url
369
- // client the ENV VAR NAMES holding our own id and secret, never the values
612
+ // client the NAMES of our own id and secret, never the values — keys into
613
+ // the credential map the provider collection answers, matching a
614
+ // provider field's `credential`
370
615
  // redirect our callback path — DECLARED, never derived from the slug. It is
371
616
  // registered in the vendor's console and they refuse anything that
372
617
  // does not byte-match, so it is a fact about someone else's records
@@ -531,6 +776,38 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
531
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"/>
532
777
  </svg>`;
533
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
+
534
811
  // Attentive — SMS-first marketing, installed as a distributed Attentive app.
535
812
  //
536
813
  // EVERY VENDOR FACT BELOW IS CITED from docs.attentive.com (fetched 2026-09-01):
@@ -562,8 +839,9 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
562
839
  //
563
840
  // DORMANT UNTIL REGISTERED. `requires` names the client credentials that only
564
841
  // exist once the Attentive app is created (their console: enable distribution,
565
- // set the redirect URL, Generate Credentials). Until those env vars land, no
566
- // deployment offers this connection — the manifest ships complete and inert.
842
+ // set the redirect URL, Generate Credentials). Until an admin enters them on
843
+ // the provider screen, no deployment offers this connection — the manifest
844
+ // ships complete and inert.
567
845
  //
568
846
  // THREE THINGS TO VERIFY AT REGISTRATION, because the docs conflict or are
569
847
  // silent, and only a live install answers them:
@@ -584,9 +862,12 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
584
862
  var attentive = {
585
863
  auth : {
586
864
  oauth : {
587
- // NAMES of the env vars holding OUR app's client — set at registration,
588
- // never before. No `headers` on the client: Attentive takes credentials
589
- // as form fields, which is the runner's default.
865
+ // NAMES of the credentials holding OUR app's client — keys into the map
866
+ // the provider collection answers, entered on the admin screen at
867
+ // registration, never before. (The names are the env vars they once
868
+ // were; the vocabulary stayed when the storage moved.) No `headers` on
869
+ // the client: Attentive takes credentials as form fields, which is the
870
+ // runner's default.
590
871
  client : {
591
872
  id : 'ATTENTIVE_OAUTH_CLIENT_ID',
592
873
  secret : 'ATTENTIVE_OAUTH_CLIENT_SECRET'
@@ -611,9 +892,10 @@ var attentive = {
611
892
  // has to say so rather than let them believe otherwise.
612
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.',
613
894
  description : [
614
- '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.',
615
896
  'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
616
- '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.'
617
899
  ],
618
900
  excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
619
901
  guide : [
@@ -633,7 +915,11 @@ var attentive = {
633
915
  label : 'Attentive segment',
634
916
  message : 'Contacts your campaigns collect are synced into this segment.',
635
917
  hook : 'resources.audiences',
636
- 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.
637
923
  // No `search : false` here, and that is a first: /v2/segments takes a
638
924
  // `name` filter (partial match, cited above), so this picker searches
639
925
  // the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
@@ -647,17 +933,25 @@ var attentive = {
647
933
  hooks : {
648
934
 
649
935
  auth : {
650
- // The exchange already yields the tokens, and Attentive documents no
651
- // account-identity endpoint to enrich them with — Klaviyo's connect
652
- // reads the account name back; this has nothing cited to read. The
653
- // callback stores the tokens and skips enrichment on `unimplemented`.
654
936
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
655
- // dependencies", and nothing anywhere implements either of these —
656
- // there is nothing for them to do. The exchange already yields the
657
- // tokens and Attentive documents no account-identity endpoint to
658
- // enrich them with, so connect has nothing to add; and they document
659
- // no revocation endpoint at all, so disconnect has nothing to call.
660
- // 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.
661
955
  connect : false,
662
956
  disconnect : false,
663
957
  probe : false,
@@ -682,7 +976,154 @@ var attentive = {
682
976
  }
683
977
  },
684
978
  commerce : false,
685
- 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
+ },
686
1127
  email : false,
687
1128
  inbound : false,
688
1129
  lifecycle : false,
@@ -696,7 +1137,7 @@ var attentive = {
696
1137
  // show a picker quietly missing most of a real account. The response's
697
1138
  // only identifier is `externalId`, so an entry without one cannot be
698
1139
  // stored and is dropped.
699
- audiences : async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
1140
+ audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
700
1141
 
701
1142
  const query = new URLSearchParams({
702
1143
  limit : String( Math.min( limit, 1000 ) ),
@@ -704,24 +1145,7 @@ var attentive = {
704
1145
  ...( search?.value && { name : String( search.value ).trim() } )
705
1146
  });
706
1147
 
707
- const response = await fetcher(
708
- 'https://api.attentivemobile.com/v2/segments?' + query,
709
- {
710
- headers : { authorization : 'Bearer ' + token },
711
- signal : AbortSignal.timeout( 15000 )
712
- }
713
- );
714
-
715
- if( ! response.ok ){
716
-
717
- throw Object.assign(
718
- new Error( 'Attentive refused the request (' + response.status + ')' ),
719
- { status : response.status }
720
- );
721
-
722
- }
723
-
724
- const body = await response.json();
1148
+ const body = await api$2( '/v2/segments?' + query, { fetcher, token });
725
1149
 
726
1150
  return {
727
1151
  items : ( body?.segments || [] )
@@ -745,33 +1169,79 @@ var attentive = {
745
1169
 
746
1170
  },
747
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
+ },
748
1186
  requires : [
749
1187
  'ATTENTIVE_OAUTH_CLIENT_ID',
750
1188
  'ATTENTIVE_OAUTH_CLIENT_SECRET'
751
1189
  ],
752
1190
  slug : 'attentive',
753
- // A consent with no segment chosen is authenticated and inert — the sync,
754
- // 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.
755
1194
  status : ( data ) => ( data?.settings?.segment ? data.status : 'pending' ),
756
- // No steps: subscriber sync has not shipped, so this vendor contributes
757
- // nothing to a workflow yet. An empty steps object is the honest declaration.
758
- steps : {},
759
- tasks : ( data ) => [
760
- ...( data?.settings?.segment
761
- ? []
762
- : [
763
- {
764
- message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
765
- title : 'Choose a segment'
766
- }
767
- ]
768
- ),
769
- {
770
- message : 'Contact syncing to Attentive segments has not shipped yet. Nothing is being sent to Attentive right now.',
771
- title : 'Subscriber sync not available yet',
772
- 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
+
773
1232
  }
774
- ],
1233
+
1234
+ },
1235
+ // WHY, in the merchant's words, and what to do about it.
1236
+ tasks : ( data ) => ( data?.settings?.segment
1237
+ ? []
1238
+ : [
1239
+ {
1240
+ message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
1241
+ title : 'Choose a segment'
1242
+ }
1243
+ ]
1244
+ ),
775
1245
  title : 'Attentive'
776
1246
  };
777
1247
 
@@ -809,15 +1279,22 @@ var attentive = {
809
1279
 
810
1280
  const HUBSPOT_BASE = 'https://api.hubapi.com';
811
1281
 
812
- // Best-effort: with no Private App token configured the whole integration
813
- // no-ops so a missing secret never crashes sync. Callers wrap these in
814
- // try/catch and treat a missing id gracefully.
1282
+ // THE TOKEN IS THE CALLER'S TO PASS. It used to fall back to process.env; it
1283
+ // lives encrypted in the `provider` collection now (lib/providers.js).
1284
+ //
1285
+ // THROWS RATHER THAN DEGRADES, once a request is actually being made. Whether
1286
+ // the portal is configured at all is decided by the callers below — the token is
1287
+ // declared optional on purpose — but a path that got as far as here without one
1288
+ // would send `Bearer undefined` and read the 401 as HubSpot being down. The
1289
+ // absence has to be named where it happens.
815
1290
  const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
816
1291
 
1292
+ if( ! token ) throw new Error( 'HubSpot access token missing — pass token (the drawbridge provider\'s hubspotToken)' );
1293
+
817
1294
  return ( fetcher || request )({
818
1295
  body,
819
1296
  headers : {
820
- 'Authorization' : 'Bearer ' + ( token || process.env.HUBSPOT_ACCESS_TOKEN )
1297
+ 'Authorization' : 'Bearer ' + token
821
1298
  },
822
1299
  method,
823
1300
  query,
@@ -1012,13 +1489,16 @@ const contacts = {
1012
1489
  // FORGET A CONTACT, by id or by email. Account deletion — the caller had
1013
1490
  // to search then remove, which is one round trip it should not have to
1014
1491
  // know about.
1015
- remove : async ({ email, fetcher, id, token }) => {
1016
-
1017
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1492
+ remove : async ( { email, id, token }, { fetcher } = {} ) => {
1018
1493
 
1019
- if( ! key ) return;
1494
+ // NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
1495
+ // difference is what the credential is declared to be: hubspotToken
1496
+ // is the one provider field that is not `required`, because this is
1497
+ // internal CRM tooling no merchant sees. A deployment with no portal
1498
+ // is a supported state, not a missing credential.
1499
+ if( ! token ) return;
1020
1500
 
1021
- const contact = id || await lookup({ email, fetcher, token : key });
1501
+ const contact = id || await lookup({ email, fetcher, token });
1022
1502
 
1023
1503
  if( ! contact ) return;
1024
1504
 
@@ -1026,7 +1506,7 @@ const contacts = {
1026
1506
  fetcher,
1027
1507
  method : 'DELETE',
1028
1508
  path : '/crm/v3/objects/contacts/' + contact,
1029
- token : key
1509
+ token
1030
1510
  });
1031
1511
 
1032
1512
  },
@@ -1037,19 +1517,18 @@ const contacts = {
1037
1517
  // no delete-old-then-create-new.
1038
1518
  //
1039
1519
  // Prefer the cached hubspotId; fall back to a search; create last.
1040
- sync : async ({ doc, fetcher, token }) => {
1041
-
1042
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1520
+ sync : async ( { doc, token }, { fetcher } = {} ) => {
1043
1521
 
1044
- // NO TOKEN IS A NO-OP, not an error. A deployment without a portal
1045
- // configured must not crash the user stream.
1046
- if( ! key ) return;
1522
+ // NO TOKEN IS A NO-OP, not an error see remove() above. A
1523
+ // deployment without a portal configured must not crash the user
1524
+ // stream over internal tooling.
1525
+ if( ! token ) return;
1047
1526
 
1048
1527
  if( doc?.hubspotId ){
1049
1528
 
1050
1529
  try {
1051
1530
 
1052
- return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token : key }) )?.id;
1531
+ return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token }) )?.id;
1053
1532
 
1054
1533
  } catch ( error ){
1055
1534
 
@@ -1061,14 +1540,14 @@ const contacts = {
1061
1540
 
1062
1541
  }
1063
1542
 
1064
- const existing = await lookup({ email : doc?.email, fetcher, token : key });
1543
+ const existing = await lookup({ email : doc?.email, fetcher, token });
1065
1544
 
1066
1545
  return ( await send({
1067
1546
  doc,
1068
1547
  fetcher,
1069
1548
  method : existing ? 'PATCH' : 'POST',
1070
1549
  path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
1071
- token : key
1550
+ token
1072
1551
  }) )?.id;
1073
1552
 
1074
1553
  }
@@ -1085,7 +1564,7 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
1085
1564
  <rect width="500" height="500" fill="#BAEC5F"/>
1086
1565
  <g clip-path="url(#clip0_2115_2832)">
1087
1566
  <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"/>
1088
- <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"/>
1567
+ <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"/>
1089
1568
  </g>
1090
1569
  <defs>
1091
1570
  <clipPath id="clip0_2115_2832">
@@ -1110,6 +1589,82 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
1110
1589
  // answers "can this merchant connect it" through `requires` and a plan feature.
1111
1590
  // A private one is always present, in every deployment, for every organization —
1112
1591
  // so it declares neither, and build() knows not to ask.
1592
+
1593
+ // Replace {{key}} placeholders with values from the run's accumulated context.
1594
+ // An unresolved key is left as it was written rather than blanked: a merchant
1595
+ // reading "Hello {{name}}" knows their template is wrong, where "Hello " reads
1596
+ // like a person with no name.
1597
+ //
1598
+ // Moved here from drawbridge-sync's lib/step.js with the bodies that were its
1599
+ // only callers. It is a string function — nothing about it needed a service.
1600
+ const interpolate = ( template, data ) => {
1601
+
1602
+ if( ! template ) return template;
1603
+
1604
+ return template.replace( /\{\{(\w+)\}\}/g, ( _, key ) => ( data?.[ key ] != null ? String( data[ key ] ) : '{{' + key + '}}' ) );
1605
+
1606
+ };
1607
+
1608
+ // WHO ON THE TEAM GETS TOLD. Shared by email.notify and email.digest, which
1609
+ // resolved it identically — the same forty lines twice, and the dedupe rule is
1610
+ // subtle enough that two copies would eventually differ.
1611
+ //
1612
+ // The OWNER IS ALWAYS A RECIPIENT. `settings.members` is an optional list of
1613
+ // ADDITIONAL people: the members endpoint is owner-gated and the owner is not a
1614
+ // `member` document, so a solo merchant has nothing selectable and could
1615
+ // otherwise not use these steps at all.
1616
+ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1617
+
1618
+ const org = await read.get({ collection : 'organization', query : { id : organization } });
1619
+
1620
+ const owner = org?.owner
1621
+ ? await read.get({ collection : 'user', query : { id : org.owner } })
1622
+ : null;
1623
+
1624
+ // Re-scoped to the org and to ACCEPTED members: the id list is stored on the
1625
+ // step and outlives the membership it names — someone who declined, was
1626
+ // removed, or never accepted.
1627
+ const members = memberIds.length
1628
+ ? await read.aggregate({
1629
+ collection : 'member',
1630
+ pipeline : [
1631
+ {
1632
+ $match : {
1633
+ id : { $in : memberIds },
1634
+ organization,
1635
+ status : 'accepted'
1636
+ }
1637
+ }
1638
+ ]
1639
+ })
1640
+ : [];
1641
+
1642
+ // Deduped by address, lower-cased and NO FURTHER. The owner is commonly also
1643
+ // named in settings.members and nobody should get two copies. Canonicalizing
1644
+ // (toCanonicalEmail) would be wrong: it strips +tags, so two genuinely
1645
+ // different teammates collapse into one and the second is never told.
1646
+ const seen = new Set();
1647
+
1648
+ return [ owner, ...members ].filter( ( member ) => {
1649
+
1650
+ if( ! member?.id || ! member?.email ) return false;
1651
+
1652
+ const address = member.email.toLowerCase();
1653
+
1654
+ if( seen.has( address ) ) return false;
1655
+
1656
+ seen.add( address );
1657
+
1658
+ return true;
1659
+
1660
+ });
1661
+
1662
+ };
1663
+
1664
+ // ONE NOTIFICATION, DESCRIBED. queue/notification.js owns delivery, the
1665
+ // unsubscribe token and the CAN-SPAM footer — these steps only say who and what.
1666
+ const queueNotification = ( data ) => ({ collection : 'notification', data, operation : 'create' });
1667
+
1113
1668
  var drawbridge = {
1114
1669
  auth : {
1115
1670
  type : 'none'
@@ -1127,10 +1682,15 @@ var drawbridge = {
1127
1682
  exclusive : false,
1128
1683
  fields : [],
1129
1684
  group : 'developer',
1130
- // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
1131
- // suppression collection and the queues; segment sync needs the streams. A
1132
- // published package carrying those makes every consumer carry them, which is
1133
- // the reason `{}` exists as an answer.
1685
+ // THE BODIES LIVE HERE, beside the declarations that name them. They used to
1686
+ // live in drawbridge-sync because they touch the database, the queues and the
1687
+ // sockets and a published package cannot carry a controller.
1688
+ //
1689
+ // It does not have to. A hook is a function, so everything it needs is PASSED
1690
+ // IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
1691
+ // for the one write whose RESULT the hook has to count. Everything else a hook
1692
+ // wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
1693
+ // performs it. See lib/connections/contract.js for that shape.
1134
1694
  hooks : {
1135
1695
  auth : {
1136
1696
  // Nothing to connect, revoke, probe or re-scope.
@@ -1151,46 +1711,538 @@ var drawbridge = {
1151
1711
  // accounts DRAWBRIDGE holds rather than ones a merchant connects.
1152
1712
  contacts,
1153
1713
  email : {
1154
- digest : {},
1155
- // To organization members. NEVER suppressed and never billed: an
1156
- // entrant's opt-out must not silence an alert to staff, and staff mail is
1157
- // not a metered send.
1158
- notify : {},
1159
- // To a lead. Suppression applies and the send is billed.
1160
- send : {}
1161
- },
1162
- inbound : false,
1163
- lifecycle : false,
1164
- resources : {
1165
- audiences : false,
1166
- prices : false,
1167
- products : false,
1168
- promotions : false
1169
- },
1170
- segment : { sync : {} },
1171
- sms : { send : {} },
1172
- webhook : false
1173
- },
1174
- icon: icon$3,
1175
- // PRIVATE: never in the catalog, always available to the builder.
1176
- private : true,
1177
- // NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
1178
- //
1179
- // `requires` gates AVAILABILITY: a name in it that is unset removes the whole
1180
- // connection. This one is private and contributes every base workflow step —
1181
- // email.send, sms.send, segment.sync — so gating it on a CRM token would take
1182
- // all of them away from any deployment without a HubSpot portal, to protect a
1183
- // sync that is best-effort and already no-ops without a token.
1184
- //
1185
- // The test 'a vendor is only available when its environment is configured'
1186
- // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
1187
- // empty the moment this was added.
1188
- requires : [],
1189
- slug : 'drawbridge',
1190
- // Always on. There is no credential that could go bad and no configuration a
1191
- // merchant could leave half-finished.
1192
- status : () => 'active',
1193
- // DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
1714
+
1715
+ // A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
1716
+ // lead.
1717
+ //
1718
+ // The count is the point: `email.notify` tells the owner one lead arrived
1719
+ // and dampens a spike to one message per bucket, which is deliberately not
1720
+ // a count. This is where "you got 43 entries this week" comes from.
1721
+ digest : async ( { context, step, workflow }, { read } = {} ) => {
1722
+
1723
+ // The window comes from the TRIGGER that fired it, so Daily/Weekly/
1724
+ // Monthly each summarise their own period without a second setting to
1725
+ // keep in step.
1726
+ const days = { day : 1, month : 30, week : 7 }[ workflow?.trigger?.event ] || 7;
1727
+
1728
+ const since = new Date( Date.now() - ( days * 24 * 60 * 60 * 1000 ) );
1729
+ const campaign = workflow?.trigger?.filters?.campaign || null;
1730
+
1731
+ const [ counted ] = await read.aggregate({
1732
+ collection : 'lead',
1733
+ pipeline : [
1734
+ {
1735
+ $match : {
1736
+ createdAt : { $gte : since },
1737
+ organization : workflow.organization,
1738
+ ...( campaign && { campaigns : { $in : [ campaign ] } })
1739
+ }
1740
+ },
1741
+ { $count : 'count' }
1742
+ ]
1743
+ });
1744
+
1745
+ const count = Number( counted?.count || 0 );
1746
+
1747
+ const request = { campaign, count, days };
1748
+
1749
+ // Nothing happened, so nobody is told. A digest reading "0 new leads" is
1750
+ // mail the merchant did not ask for and would learn to ignore.
1751
+ if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1752
+
1753
+ const recipients = await teamRecipients({
1754
+ memberIds : step.settings?.members || [],
1755
+ organization : workflow.organization,
1756
+ read
1757
+ });
1758
+
1759
+ // `count` joins the interpolation values so a merchant can write
1760
+ // "{{count}} new entries this week" in the step's own copy.
1761
+ const values = { ...context, count };
1762
+
1763
+ return {
1764
+ message : 'Digest of ' + count + ' new lead(s) queued for ' + recipients.length + ' recipient(s).',
1765
+ request,
1766
+ response : { count, notified : recipients.length },
1767
+ writes : recipients.map( ( member ) => queueNotification({
1768
+ audience : 'member',
1769
+ message : interpolate( step.settings?.message, values ),
1770
+ organization : workflow.organization,
1771
+ send : { type : 'email', email : member.email },
1772
+ title : interpolate( step.settings?.subject, values ),
1773
+ workflow : workflow.id
1774
+ }) )
1775
+ };
1776
+
1777
+ },
1778
+
1779
+ // To the organization's OWN PEOPLE. Never suppressed, never
1780
+ // subscription-gated, no unsubscribe footer — telling an org's staff about
1781
+ // their own leads is not commercial mail to a stranger.
1782
+ //
1783
+ // FREE, permanently. The lead that triggered this run already consumed the
1784
+ // billable action, and `members` is a list — billing here would turn one
1785
+ // lead into five more charges and the org would be paying to read its own
1786
+ // mail. The declaration prices it at zero; the shell bills nothing for
1787
+ // zero.
1788
+ notify : async ( { context, step, workflow }, { read } = {} ) => {
1789
+
1790
+ const memberIds = step.settings?.members || [];
1791
+
1792
+ const request = { members : memberIds.length };
1793
+
1794
+ const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1795
+
1796
+ if( ! recipients.length ){
1797
+
1798
+ return {
1799
+ message : 'No owner or accepted member with an email address — team notification skipped.',
1800
+ request,
1801
+ response : { skipped : true },
1802
+ skipped : true
1803
+ };
1804
+
1805
+ }
1806
+
1807
+ // SPIKE DAMPER, as a fixed window rather than a sliding one.
1808
+ //
1809
+ // This used `coalesce`, which is supersede-and-delay: every new entry
1810
+ // pre-empted the pending job and re-enqueued it 30 seconds out. Under a
1811
+ // genuine spike — entries arriving faster than one per 30s, which is
1812
+ // what viral means — delivery deferred indefinitely and the owner heard
1813
+ // NOTHING until traffic dropped, precisely when they would most want to
1814
+ // know.
1815
+ //
1816
+ // `key` is first-write-wins against a partial unique index: the first
1817
+ // entry in each bucket creates the notification and it goes out
1818
+ // immediately, the rest collide and are refused. A spike yields at most
1819
+ // one heads-up per recipient per bucket, promptly, instead of one
1820
+ // eventually or never.
1821
+ //
1822
+ // Deliberately NOT a change to `coalesce` itself — stream/transaction.js
1823
+ // uses it for balance and credit notices, and its semantics are not ours
1824
+ // to redefine from here.
1825
+ const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1826
+
1827
+ return {
1828
+ message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1829
+ request,
1830
+ response : { notified : recipients.length },
1831
+ writes : recipients.map( ( member ) => ({
1832
+ ...queueNotification({
1833
+ audience : 'member',
1834
+ // Per workflow, recipient AND bucket, so one recipient's damper
1835
+ // can never swallow another's mail and a later bucket is never
1836
+ // mistaken for a duplicate of an earlier one.
1837
+ key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1838
+ message : interpolate( step.settings?.message, context ),
1839
+ organization : workflow.organization,
1840
+ send : { type : 'email', email : member.email },
1841
+ title : interpolate( step.settings?.subject, context ),
1842
+ workflow : workflow.id
1843
+ }),
1844
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1845
+ // told within the bucket. Declared per write rather than assumed by
1846
+ // the shell, because on every other write here a duplicate key is a
1847
+ // real failure.
1848
+ ignoreDuplicate : true
1849
+ }) )
1850
+ };
1851
+
1852
+ },
1853
+
1854
+ // Drawbridge sends lead-facing email itself — no merchant provider gates
1855
+ // it.
1856
+ //
1857
+ // This QUEUES rather than sends: queue/notification.js owns delivery, the
1858
+ // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
1859
+ // and what, correctly, and to refuse early when it must not send at all.
1860
+ send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
1861
+
1862
+ const to = context?.email;
1863
+
1864
+ if( ! to ) throw new Error( 'No email address on context (context.email is required)' );
1865
+
1866
+ const request = { to };
1867
+
1868
+ // PRE-CHECKED HERE so an opted-out recipient skips WITHOUT billing. The
1869
+ // send() gate in queue/notification.js would cancel the doc anyway, but
1870
+ // only after the action had already counted.
1871
+ //
1872
+ // `canSend` is INJECTED rather than read through `read`: suppression is
1873
+ // the only consent source and canSend is its only reader, so a hook
1874
+ // querying the collection itself would be a second reader of the
1875
+ // opt-out floor — and the second one is the one that gets the query
1876
+ // wrong.
1877
+ const { ok : sendable } = await canSend({ channel : 'email', to });
1878
+
1879
+ if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
1880
+
1881
+ // A LIVE SUBSCRIPTION, in two reads. Free organizations get system mail
1882
+ // only; a lead-facing send is a paid feature.
1883
+ const organization = await read.get({ collection : 'organization', query : { id : workflow.organization } });
1884
+
1885
+ const subscription = organization?.subscription
1886
+ ? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
1887
+ : null;
1888
+
1889
+ if( subscription?.status !== 'active' ){
1890
+
1891
+ return {
1892
+ message : 'Organization has no active subscription — workflow-step email skipped.',
1893
+ request,
1894
+ response : { skipped : true },
1895
+ skipped : true
1896
+ };
1897
+
1898
+ }
1899
+
1900
+ return {
1901
+ message : 'Email queued for delivery to ' + to + '.',
1902
+ request,
1903
+ response : { queued : true },
1904
+ // NO `connection` FIELD, deliberately: the platform sends this.
1905
+ // `audience : 'lead'` states what the queue would otherwise infer from
1906
+ // shape.
1907
+ //
1908
+ // `campaign` is not decoration. queue/notification.js mints the
1909
+ // unsubscribe token with it, so it decides whether opting out is
1910
+ // scoped to this campaign or the whole organization, and it names the
1911
+ // campaign in the footer. Sending without it silently broadens every
1912
+ // opt-out to the entire organization.
1913
+ writes : [
1914
+ queueNotification({
1915
+ audience : 'lead',
1916
+ campaign : context?.campaign || null,
1917
+ lead : context?.lead || null,
1918
+ message : interpolate( step.settings?.message, context ),
1919
+ organization : workflow.organization,
1920
+ send : { type : 'email', email : to },
1921
+ title : interpolate( step.settings?.subject, context ),
1922
+ workflow : workflow.id
1923
+ })
1924
+ ]
1925
+ };
1926
+
1927
+ }
1928
+
1929
+ },
1930
+ inbound : false,
1931
+ lifecycle : false,
1932
+ resources : {
1933
+ audiences : false,
1934
+ prices : false,
1935
+ products : false,
1936
+ promotions : false
1937
+ },
1938
+ segment : {
1939
+
1940
+ // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
1941
+ // contact in an organization against every segment, which is too much for
1942
+ // one job, so it returns chunks and the shell defers completion.
1943
+ //
1944
+ // Returning `chunks` is the only thing that makes it different. The
1945
+ // declaration, the guards, the step document and the price are the shell's,
1946
+ // exactly as they are for a step that finishes in one go.
1947
+ sync : async ( { context, step }, { chunkSize, logger, read, resolveContact } = {} ) => {
1948
+
1949
+ // The fan-out width is the WORKER FLEET'S number, not the vendor's — how
1950
+ // much work one job may carry is a fact about the machines running it.
1951
+ // Required rather than defaulted, because a default here would silently
1952
+ // disagree with the deployment's own tuning and nothing would say so.
1953
+ if( ! chunkSize ) throw new Error( 'segment.sync needs chunkSize from the shell' );
1954
+
1955
+ const organization = context?.organization;
1956
+
1957
+ const configured = step?.settings?.segment;
1958
+
1959
+ const request = { organization : organization || null, segmentId : configured || null };
1960
+
1961
+ // A segment sitting at `syncing` with nothing running is a spinner that
1962
+ // never stops, so every exit below puts it back — DESCRIBED, and
1963
+ // performed by the shell. Including the throwing exit: the effects ride
1964
+ // out on the error, which is the only way a rejection can still release
1965
+ // what it locked.
1966
+ const release = ( ids, status = 'active' ) => {
1967
+
1968
+ const released = ( ids || [] ).filter( Boolean );
1969
+
1970
+ return {
1971
+ events : organization
1972
+ ? released.map( ( id ) => ({
1973
+ event : 'organization.segments',
1974
+ payload : { id, status },
1975
+ room : 'organization.' + organization
1976
+ }) )
1977
+ : [],
1978
+ writes : released.map( ( id ) => ({
1979
+ collection : 'segment',
1980
+ data : { $set : { status } },
1981
+ operation : 'update',
1982
+ query : { id }
1983
+ }) )
1984
+ };
1985
+
1986
+ };
1987
+
1988
+ if( ! organization ){
1989
+
1990
+ return {
1991
+ ...release([ configured ]),
1992
+ message : 'Trigger data missing organization id — cannot sync segments.',
1993
+ request,
1994
+ response : { skipped : true },
1995
+ skipped : true
1996
+ };
1997
+
1998
+ }
1999
+
2000
+ const segments = await read.aggregate({
2001
+ collection : 'segment',
2002
+ pipeline : [ { $match : configured ? { id : configured, organization } : { organization } } ]
2003
+ });
2004
+
2005
+ if( ! segments.length ){
2006
+
2007
+ return {
2008
+ ...release([ configured ]),
2009
+ message : 'No segments matched the request — nothing to sync.',
2010
+ request,
2011
+ response : { skipped : true },
2012
+ skipped : true
2013
+ };
2014
+
2015
+ }
2016
+
2017
+ const segmentIds = segments.map( ( entry ) => entry.id );
2018
+
2019
+ try {
2020
+
2021
+ let backfilled = 0;
2022
+
2023
+ // A SYSTEM SEGMENT evaluates contacts, so any lead without one has to
2024
+ // get one first or it can never be a member of anything.
2025
+ if( segments.some( ( entry ) => entry.system ) ){
2026
+
2027
+ const contacted = await read.aggregate({
2028
+ collection : 'contact',
2029
+ pipeline : [
2030
+ { $match : { organization } },
2031
+ { $project : { _id : 0, leads : 1 } },
2032
+ { $unwind : '$leads' },
2033
+ { $group : { _id : null, ids : { $addToSet : '$leads' } } }
2034
+ ]
2035
+ });
2036
+
2037
+ const uncontacted = await read.aggregate({
2038
+ collection : 'lead',
2039
+ pipeline : [
2040
+ { $match : { id : { $nin : contacted[ 0 ]?.ids || [] }, organization } },
2041
+ { $project : { _id : 0, id : 1 } }
2042
+ ]
2043
+ });
2044
+
2045
+ // THE ONE WRITE THIS HOOK CANNOT DESCRIBE, and the reason it is
2046
+ // injected instead. Its RESULT is an input to what the hook returns:
2047
+ // how many contacts this run had to create decides whether the
2048
+ // chunks bill, and a description cannot be counted before it runs.
2049
+ //
2050
+ // So the caller supplies the collaborator — the same shape as the
2051
+ // `shopify` SDK injection elsewhere in this directory — and the
2052
+ // hook still reaches for no controller of its own.
2053
+ for( const lead of uncontacted ){
2054
+
2055
+ try {
2056
+
2057
+ await resolveContact({ leadId : lead.id });
2058
+
2059
+ backfilled += 1;
2060
+
2061
+ } catch ( error ){
2062
+
2063
+ // A duplicate means a concurrent resolve created it first, which
2064
+ // is the outcome we wanted.
2065
+ if( error.code !== 11000 ) throw error;
2066
+
2067
+ }
2068
+
2069
+ }
2070
+
2071
+ logger?.info?.( 'segment.sync.backfill', { backfilled, organization, uncontacted : uncontacted.length });
2072
+
2073
+ }
2074
+
2075
+ const contacts = await read.aggregate({
2076
+ collection : 'contact',
2077
+ pipeline : [
2078
+ { $match : { organization } },
2079
+ { $project : { _id : 0, id : 1 } },
2080
+ { $sort : { id : 1 } }
2081
+ ]
2082
+ });
2083
+
2084
+ if( ! contacts.length ){
2085
+
2086
+ return {
2087
+ ...release( segmentIds ),
2088
+ message : 'Organization has no contacts to evaluate against segments.',
2089
+ request,
2090
+ response : { skipped : true },
2091
+ skipped : true
2092
+ };
2093
+
2094
+ }
2095
+
2096
+ const contactIds = contacts.map( ( contact ) => contact.id );
2097
+
2098
+ const org = await read.get({ collection : 'organization', query : { id : organization } });
2099
+
2100
+ // THE FAN-OUT. `chunks` tells the shell to open the step rather than
2101
+ // close it, and the SHELL builds one slot per chunk — the slot is the
2102
+ // step document's own shape, so counting them here was a manifest
2103
+ // carrying a schema that is not its.
2104
+ const chunks = [];
2105
+
2106
+ for( let index = 0 ; index < contactIds.length ; index += chunkSize ){
2107
+
2108
+ chunks.push({
2109
+ contactIds : contactIds.slice( index, index + chunkSize ),
2110
+ organization,
2111
+ segments : segmentIds,
2112
+ // A BACKFILL IS NOT BILLABLE. It creates the contacts this run
2113
+ // then evaluates, so charging for it would bill an organization
2114
+ // for work its own history made necessary.
2115
+ usage : ( context?.billable === true && backfilled === 0 ) ? org?.usage || null : null
2116
+ });
2117
+
2118
+ }
2119
+
2120
+ return {
2121
+ chunks,
2122
+ ...( configured && { extra : { segment : configured } }),
2123
+ message : 'Queued ' + contactIds.length + ' contacts across ' + chunks.length + ' chunks for segment evaluation.',
2124
+ queue : 'segment',
2125
+ request : { ...request, segments : segmentIds },
2126
+ response : { chunks : chunks.length, contacts : contactIds.length, segments : segments.length }
2127
+ };
2128
+
2129
+ } catch ( error ){
2130
+
2131
+ // A segment left mid-sync shows as errored rather than syncing forever.
2132
+ // The release rides OUT ON THE ERROR because a throw is how a hook
2133
+ // rejects, and the shell performs a rejection's effects before it
2134
+ // records the failure.
2135
+ throw Object.assign( error, release( segmentIds, 'error' ) );
2136
+
2137
+ }
2138
+
2139
+ }
2140
+
2141
+ },
2142
+ sms : {
2143
+
2144
+ // SMS TO A LEAD, through the merchant's own Twilio connection.
2145
+ //
2146
+ // WITHDRAWN from the builder — twilio went, and a connection-gated step
2147
+ // with no connection to gate on could only ever render permanently
2148
+ // disabled. Stored workflows still carry it, so it still runs.
2149
+ //
2150
+ // It looks its own connection up rather than relying on the shell, because
2151
+ // the step is declared by the PRIVATE drawbridge connection (which has
2152
+ // none) while the credential belongs to twilio (which has no manifest).
2153
+ // Platform SMS will remove that split the way it did for email.
2154
+ send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
2155
+
2156
+ const to = context?.phone?.number;
2157
+
2158
+ if( ! to ) throw new Error( 'No phone number on context (context.phone.number is required)' );
2159
+
2160
+ const request = { to };
2161
+
2162
+ const connection = await read.get({
2163
+ collection : 'connection',
2164
+ query : { organization : workflow.organization, slug : 'twilio', status : 'active' }
2165
+ });
2166
+
2167
+ if( ! connection ) return { message : 'No active Twilio SMS connection — workflow-step SMS skipped.', request, response : { skipped : true }, skipped : true };
2168
+
2169
+ // PRE-CHECKED so an opted-out recipient skips WITHOUT billing. The
2170
+ // carrier opt-out is a legal obligation, not a preference.
2171
+ const { ok : sendable } = await canSend({ channel : 'sms', to : context.phone });
2172
+
2173
+ if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
2174
+
2175
+ return {
2176
+ message : 'SMS queued for delivery to ' + to + ' via twilio.',
2177
+ request,
2178
+ response : { provider : 'twilio', queued : true },
2179
+ // QUEUES rather than sends: queue/notification.js owns delivery, the
2180
+ // carrier opt-out line and the segment count this is billed on.
2181
+ writes : [
2182
+ queueNotification({
2183
+ connection : connection.id,
2184
+ message : interpolate( step.settings?.message, context ),
2185
+ organization : workflow.organization,
2186
+ send : { phone : { number : to }, type : 'phone' },
2187
+ title : interpolate( step.settings?.subject, context ),
2188
+ workflow : workflow.id
2189
+ })
2190
+ ]
2191
+ };
2192
+
2193
+ }
2194
+
2195
+ },
2196
+ webhook : false
2197
+ },
2198
+ icon: icon$3,
2199
+ // PRIVATE: never in the catalog, always available to the builder.
2200
+ private : true,
2201
+ // THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
2202
+ // HubSpot portal. No merchant ever sees these; they are what an admin types on
2203
+ // the provider screen so that Drawbridge itself can send.
2204
+ //
2205
+ // They belong on THIS manifest because this is the connection that sends: the
2206
+ // email, sms and segment hooks below are the only things that spend them, and
2207
+ // a private connection is still where a vendor fact lives.
2208
+ //
2209
+ // UNLIKE every public vendor, none of these appear in `requires` — see the
2210
+ // comment there. Availability and configuration are different questions, and a
2211
+ // missing CRM token must not take every base workflow step away.
2212
+ provider : {
2213
+ fields : [
2214
+ { input : 'email', key : 'accountSender', credential : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
2215
+ { input : 'password', key : 'apiKey', credential : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
2216
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
2217
+ // without a token — requiring it would make the whole drawbridge provider
2218
+ // read not-live over something no merchant ever sees.
2219
+ { 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 },
2220
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2221
+ // either. Unset, it degrades to the account sender rather than
2222
+ // refusing to start.
2223
+ { 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.' },
2224
+ { input : 'text', key : 'smsFrom', credential : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
2225
+ { input : 'password', key : 'smsSid', credential : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
2226
+ { input : 'password', key : 'smsToken', credential : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
2227
+ ]
2228
+ },
2229
+ // NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
2230
+ //
2231
+ // `requires` gates AVAILABILITY: a name in it that is unset removes the whole
2232
+ // connection. This one is private and contributes every base workflow step —
2233
+ // email.send, sms.send, segment.sync — so gating it on a CRM token would take
2234
+ // all of them away from any deployment without a HubSpot portal, to protect a
2235
+ // sync that is best-effort and already no-ops without a token.
2236
+ //
2237
+ // The test 'a vendor is only available when its environment is configured'
2238
+ // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2239
+ // empty the moment this was added.
2240
+ requires : [],
2241
+ slug : 'drawbridge',
2242
+ // Always on. There is no credential that could go bad and no configuration a
2243
+ // merchant could leave half-finished.
2244
+ status : () => 'active',
2245
+ // DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
1194
2246
  // below — trigger, billable, settings — is what that catalog and the workflow
1195
2247
  // route already enforce today, because this replaces them rather than
1196
2248
  // competing with them.
@@ -1212,11 +2264,11 @@ var drawbridge = {
1212
2264
  key : 'Email — Digest',
1213
2265
  queue : 'notification',
1214
2266
  settings : {
1215
- // The organization OWNER is always a recipient, resolved in sync,
1216
- // so this is additional recipients rather than the list. It cannot
1217
- // be required: the members endpoint is owner-gated and the owner is
1218
- // not a member document, so a solo merchant has nothing to pick and
1219
- // could never save the step.
2267
+ // The organization OWNER is always a recipient, resolved by the
2268
+ // hook, so this is additional recipients rather than the list. It
2269
+ // cannot be required: the members endpoint is owner-gated and the
2270
+ // owner is not a member document, so a solo merchant has nothing to
2271
+ // pick and could never save the step.
1220
2272
  members : { of : 'string', type : 'array' },
1221
2273
  message : { required : true, type : 'string' },
1222
2274
  subject : { required : true, type : 'string' }
@@ -1327,11 +2379,11 @@ var drawbridge = {
1327
2379
  // lib/ directly. A bare .svg import would need a bundler loader and force the
1328
2380
  // tests onto dist/, which is a worse trade than one line of wrapper.
1329
2381
  var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1330
- <rect width="500" height="500" fill="white"/>
2382
+ <rect width="500" height="500" fill="#FF4B32"/>
1331
2383
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
1332
2384
  </svg>`;
1333
2385
 
1334
- const api = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
2386
+ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
1335
2387
 
1336
2388
  const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
1337
2389
  ...( payload && { body : JSON.stringify( payload ) }),
@@ -1395,7 +2447,8 @@ var klaviyo = {
1395
2447
  // differently, and it says so in hooks.auth.token rather than as a flag here.
1396
2448
  auth : {
1397
2449
  oauth : {
1398
- // NAMES the env vars holding OUR application's client. One identity,
2450
+ // NAMES the credentials holding OUR application's client keys into
2451
+ // the stored provider credentials, not env vars. One identity,
1399
2452
  // every merchant — the token is the merchant's and arrives from their
1400
2453
  // own consent, which is what stops one organization reading another's
1401
2454
  // data.
@@ -1544,9 +2597,9 @@ var klaviyo = {
1544
2597
  // renders an empty "Klaviyo account" field, because the merchant is
1545
2598
  // never asked which account they connected — the consent already
1546
2599
  // decided it, and asking again would be a question we can answer.
1547
- connect : async ({ fetcher, tokens }) => {
2600
+ connect : async ( { tokens }, { fetcher } = {} ) => {
1548
2601
 
1549
- const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
2602
+ const body = await api$1( '/accounts', { fetcher, token : tokens.accessToken });
1550
2603
 
1551
2604
  const account = body?.data?.[ 0 ];
1552
2605
 
@@ -1563,7 +2616,7 @@ var klaviyo = {
1563
2616
  //
1564
2617
  // Basic auth with our client, exactly like the token exchange — the
1565
2618
  // token being revoked is the subject, not the credential.
1566
- disconnect : async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
2619
+ disconnect : async ( { clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {} ) => {
1567
2620
 
1568
2621
  const token = settings?.refreshToken || settings?.accessToken;
1569
2622
 
@@ -1595,7 +2648,7 @@ var klaviyo = {
1595
2648
  // the refresh token is the only thing that asks Klaviyo.
1596
2649
  //
1597
2650
  // It also keeps the grant warm against the 90-day idle window above.
1598
- probe : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
2651
+ probe : async ( { clientId, clientSecret, manifest, settings }, { fetcher } = {} ) => {
1599
2652
 
1600
2653
  const token = await accessToken({
1601
2654
  clientId,
@@ -1634,14 +2687,13 @@ var klaviyo = {
1634
2687
  commerce : false,
1635
2688
 
1636
2689
  // The verb the contacts.sync step points at. It does the work — including
1637
- // writing the profile id back onto the lead — and returns what happened.
1638
2690
  contacts : {
1639
2691
 
1640
2692
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1641
2693
  // different thing from deleting the profile.
1642
2694
  remove : false,
1643
2695
 
1644
- sync : async ({ contact, fetcher, lead, settings, suppressed, token }) => {
2696
+ sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
1645
2697
 
1646
2698
  const list = settings?.list;
1647
2699
 
@@ -1661,7 +2713,7 @@ var klaviyo = {
1661
2713
  // exist on a single-address record.
1662
2714
  const totals = contact?.totals || {};
1663
2715
 
1664
- const profile = await api( '/profiles/', {
2716
+ const profile = await api$1( '/profiles/', {
1665
2717
  fetcher,
1666
2718
  method : 'POST',
1667
2719
  payload : {
@@ -1702,7 +2754,7 @@ var klaviyo = {
1702
2754
  //
1703
2755
  // `suppressed` arrives as an argument because canSend() is sync's —
1704
2756
  // a manifest cannot reach it, and this rule is too important to infer.
1705
- await api( '/profile-subscription-bulk-create-jobs/', {
2757
+ await api$1( '/profile-subscription-bulk-create-jobs/', {
1706
2758
  fetcher,
1707
2759
  method : 'POST',
1708
2760
  payload : {
@@ -1768,7 +2820,7 @@ var klaviyo = {
1768
2820
  // it, so one call quietly returns the first ten lists and an account
1769
2821
  // with more shows a picker missing the one they wanted, with nothing to
1770
2822
  // indicate anything was cut.
1771
- audiences : async ({ cursor, fetcher, limit = 100, search, token }) => {
2823
+ audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
1772
2824
 
1773
2825
  // `limit` is what the CALLER wants back rather than what one request
1774
2826
  // can carry — the loop keeps pulling until it has that many or the
@@ -1783,7 +2835,7 @@ var klaviyo = {
1783
2835
 
1784
2836
  while( next && audiences.length < limit && pages < 20 ){
1785
2837
 
1786
- const body = await api( next, { fetcher, token });
2838
+ const body = await api$1( next, { fetcher, token });
1787
2839
 
1788
2840
  for( const list of ( body?.data || [] ) ){
1789
2841
 
@@ -1830,6 +2882,16 @@ var klaviyo = {
1830
2882
 
1831
2883
  },
1832
2884
  icon: icon$2,
2885
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
2886
+ // what an admin types on the provider screen. Declared here rather than in a
2887
+ // table in lib/providers.js, so a vendor's credentials sit beside the
2888
+ // `requires` that names the same variables.
2889
+ provider : {
2890
+ fields : [
2891
+ { input : 'text', key : 'clientId', credential : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
2892
+ { input : 'password', key : 'clientSecret', credential : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
2893
+ ]
2894
+ },
1833
2895
  requires : [
1834
2896
  'KLAVIYO_OAUTH_CLIENT_ID',
1835
2897
  'KLAVIYO_OAUTH_CLIENT_SECRET'
@@ -1937,6 +2999,54 @@ const base = ( dc ) => {
1937
2999
 
1938
3000
  };
1939
3001
 
3002
+ // ONE REQUEST SHAPE for the Marketing API, the way Klaviyo's file has one.
3003
+ //
3004
+ // Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and OAuth 2
3005
+ // tokens can be used to make authenticated requests the same way", so one header
3006
+ // serves both — the Basic form this used under keys was never OAuth-compatible.
3007
+ //
3008
+ // The host comes from base( dc ) on every call rather than being captured once:
3009
+ // there is no fixed host, and a request assembled without the stored data centre
3010
+ // is a 404 nobody can read.
3011
+ const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token } ) => {
3012
+
3013
+ const response = await fetcher( base( dc ) + path, {
3014
+ ...( payload && { body : JSON.stringify( payload ) }),
3015
+ headers : {
3016
+ authorization : 'Bearer ' + token,
3017
+ ...( payload && { 'content-type' : 'application/json' })
3018
+ },
3019
+ method,
3020
+ signal : AbortSignal.timeout( 15000 )
3021
+ });
3022
+
3023
+ if( ! response.ok ){
3024
+
3025
+ throw Object.assign(
3026
+ new Error( 'Mailchimp refused the request (' + response.status + ')' ),
3027
+ { status : response.status }
3028
+ );
3029
+
3030
+ }
3031
+
3032
+ return response.json();
3033
+
3034
+ };
3035
+
3036
+ // THE MEMBER ID IS COMPUTED, not handed back: Mailchimp addresses a member by
3037
+ // "the MD5 hash of the lowercase version of the list member's email address".
3038
+ //
3039
+ // That is what makes PUT an UPSERT — the same person hashes to the same id, so a
3040
+ // resync updates rather than creating a second member — and it is why the
3041
+ // address is lowercased before hashing rather than after: Ada@Example.com and
3042
+ // ada@example.com are one subscriber to Mailchimp and would otherwise be two.
3043
+ //
3044
+ // MD5 is the vendor's choice and nothing here is authenticated by it; it is an
3045
+ // address, not a secret.
3046
+ const subscriberHash = ( email ) => createHash( 'md5' )
3047
+ .update( String( email ).trim().toLowerCase() )
3048
+ .digest( 'hex' );
3049
+
1940
3050
  // Mailchimp — contact sync, not a sender.
1941
3051
  var mailchimp = {
1942
3052
  // OAUTH 2, authorization code. Every url below is quoted from
@@ -1980,7 +3090,9 @@ var mailchimp = {
1980
3090
  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.',
1981
3091
  description : [
1982
3092
  '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.',
1983
- '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.'
3093
+ '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.',
3094
+ '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.',
3095
+ 'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
1984
3096
  ],
1985
3097
  excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
1986
3098
  guide : [
@@ -2023,7 +3135,7 @@ var mailchimp = {
2023
3135
  // The header here is `OAuth <token>`, not Bearer — that is specific to
2024
3136
  // the metadata endpoint. Marketing API calls take Bearer; see the
2025
3137
  // audiences hook.
2026
- connect : async ({ fetcher = fetch, tokens }) => {
3138
+ connect : async ( { tokens }, { fetcher = fetch } = {} ) => {
2027
3139
 
2028
3140
  const response = await fetcher( 'https://login.mailchimp.com/oauth2/metadata', {
2029
3141
  headers : {
@@ -2066,7 +3178,89 @@ var mailchimp = {
2066
3178
  token : authToken
2067
3179
  },
2068
3180
  commerce : false,
2069
- contacts : { remove : false, sync : false },
3181
+
3182
+ // The verb the contacts.sync step points at.
3183
+ contacts : {
3184
+
3185
+ // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
3186
+ // different thing from deleting the member — and Mailchimp's own delete is
3187
+ // permanent, so the address can never be re-added.
3188
+ remove : false,
3189
+
3190
+ // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3191
+ // why there is no create-or-update branch here. Quoted from Mailchimp's
3192
+ // Marketing API reference for the list-members resource.
3193
+ sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
3194
+
3195
+ const audience = settings?.audience;
3196
+
3197
+ // status() already stops a connection reaching Active without an
3198
+ // audience; this is the belt to that braces. A workflow saved before
3199
+ // the audience was chosen must not silently write into nothing.
3200
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
3201
+
3202
+ const email = lead?.canonical?.email?.value || lead?.email;
3203
+
3204
+ // A Mailchimp member IS an email address — there is no other identity
3205
+ // to write, so a lead without one is skipped rather than failed.
3206
+ if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
3207
+
3208
+ const hash = subscriberHash( email );
3209
+
3210
+ // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
3211
+ //
3212
+ // Omitting them means Mailchimp never learns they said no, so the
3213
+ // merchant can import them from somewhere else and start mailing them
3214
+ // again. Pushing them as unsubscribed makes the suppression travel with
3215
+ // the person, which is the reason this connection is allowed to send
3216
+ // anything at all.
3217
+ //
3218
+ // `suppressed` arrives as an argument because canSend() is sync's — a
3219
+ // manifest cannot reach it, and this rule is too important to infer.
3220
+ //
3221
+ // STATUS_IF_NEW EVERYWHERE, `status` ONLY TO SUPPRESS, and the
3222
+ // asymmetry is the whole point. status_if_new applies to a member being
3223
+ // CREATED and is ignored for one that already exists, so a resync
3224
+ // cannot overwrite what a subscriber themselves chose — someone who
3225
+ // unsubscribed inside Mailchimp stays unsubscribed. An opt-out is the
3226
+ // one thing allowed to overwrite, because it travels in the direction
3227
+ // that protects the person; so suppression sets `status` outright, and
3228
+ // sets status_if_new alongside it because the upsert leg still needs a
3229
+ // status for a member Mailchimp has never seen.
3230
+ const member = await api( '/lists/' + audience + '/members/' + hash, {
3231
+ dc : settings?.dc,
3232
+ fetcher,
3233
+ method : 'PUT',
3234
+ payload : {
3235
+ email_address : email,
3236
+ // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
3237
+ // schemaless — a merge tag that does not exist on the audience is
3238
+ // refused, taking the whole request with it — and FNAME is one of
3239
+ // the two tags every audience is created with. The Drawbridge
3240
+ // totals Klaviyo receives cannot travel until something registers
3241
+ // merge fields on the chosen audience, which is lifecycle.register's
3242
+ // job and is not built.
3243
+ ...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
3244
+ ...( suppressed && { status : 'unsubscribed' }),
3245
+ status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
3246
+ },
3247
+ token
3248
+ });
3249
+
3250
+ return {
3251
+ // Merged into `context` for later steps in this run.
3252
+ context : { mailchimpMemberId : member?.id || hash },
3253
+ message : suppressed
3254
+ ? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
3255
+ : 'Synced to the Mailchimp audience.',
3256
+ // Recorded on the run for support to read back, not a write
3257
+ // instruction — the hook has already written what it needed to.
3258
+ response : { mailchimpMemberId : member?.id || hash }
3259
+ };
3260
+
3261
+ }
3262
+
3263
+ },
2070
3264
  // Drawbridge sends its own notification email and SMS, and owns its own
2071
3265
  // segments — see the private `drawbridge` manifest. A vendor answering
2072
3266
  // these would be a second sender, which is the arrangement the platform
@@ -2086,36 +3280,16 @@ var mailchimp = {
2086
3280
  // successful — the same silent truncation Klaviyo has, at a different
2087
3281
  // number. Paged against total_items so an account past a thousand still
2088
3282
  // resolves.
2089
- audiences : async ({ cursor, fetcher = fetch, limit = 100, search, settings, token }) => {
2090
-
2091
- // Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and
2092
- // OAuth 2 tokens can be used to make authenticated requests the same
2093
- // way", so one header serves both — the Basic form this used under
2094
- // keys was never OAuth-compatible.
2095
- const dc = settings?.dc;
3283
+ audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher } = {} ) => {
2096
3284
 
2097
3285
  const count = Math.min( limit, 1000 );
2098
3286
  const offset = Number( cursor || 0 );
2099
3287
 
2100
- const response = await fetcher(
2101
- base( dc ) + '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
2102
- {
2103
- headers : { authorization : 'Bearer ' + token },
2104
- signal : AbortSignal.timeout( 15000 )
2105
- }
3288
+ const body = await api(
3289
+ '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
3290
+ { dc : settings?.dc, fetcher, token }
2106
3291
  );
2107
3292
 
2108
- if( ! response.ok ){
2109
-
2110
- throw Object.assign(
2111
- new Error( 'Mailchimp refused the request (' + response.status + ')' ),
2112
- { status : response.status }
2113
- );
2114
-
2115
- }
2116
-
2117
- const body = await response.json();
2118
-
2119
3293
  const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
2120
3294
 
2121
3295
  // Mailchimp's /lists takes no name filter, so a term is matched against
@@ -2150,6 +3324,15 @@ var mailchimp = {
2150
3324
  webhook : false
2151
3325
  },
2152
3326
  icon: icon$1,
3327
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
3328
+ // what an admin types on the provider screen, beside the `requires` naming the
3329
+ // same variables.
3330
+ provider : {
3331
+ fields : [
3332
+ { input : 'text', key : 'clientId', credential : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
3333
+ { input : 'password', key : 'clientSecret', credential : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
3334
+ ]
3335
+ },
2153
3336
  // The OAuth client this deployment registered. Without both, the vendor drops
2154
3337
  // out of availableConnections rather than offering a Connect button that
2155
3338
  // cannot complete.
@@ -2158,32 +3341,66 @@ var mailchimp = {
2158
3341
  'MAILCHIMP_OAUTH_CLIENT_SECRET'
2159
3342
  ],
2160
3343
  slug : 'mailchimp',
2161
- // A key with no audience chosen is authenticated and inert. Mailchimp also
2162
- // needs its merge fields created on that audience before any Drawbridge total
2163
- // can be written to a member unlike Klaviyo, its custom fields are not
2164
- // schemaless so the audience must be picked before lifecycle.register has
2165
- // anything to register against.
3344
+ // A grant with no audience chosen is authenticated and useless the sync has
3345
+ // nowhere to put anyone so the card must say Pending rather than Active over
3346
+ // nothing. Mailchimp also needs its merge fields created on that audience
3347
+ // before any Drawbridge total can be written to a member — unlike Klaviyo, its
3348
+ // custom fields are not schemaless — so the audience must be picked before
3349
+ // lifecycle.register has anything to register against.
2166
3350
  status : ( data ) => ( data?.settings?.audience ? data.status : 'pending' ),
2167
- // No steps: audience sync has not shipped, so this vendor contributes nothing
2168
- // to a workflow yet. An empty steps object is the honest declaration — the
2169
- // catalog renders the connection, and no builder offers a step it cannot run.
2170
- steps : {},
2171
- tasks : ( data ) => [
2172
- ...( data?.settings?.audience
2173
- ? []
2174
- : [
2175
- {
2176
- message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
2177
- title : 'Choose an audience'
2178
- }
2179
- ]
2180
- ),
2181
- {
2182
- 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.',
2183
- title : 'Audience sync not available yet',
2184
- type : 'warning'
3351
+
3352
+ steps : {
3353
+
3354
+ contacts : {
3355
+
3356
+ // A DECLARATION, not the work. It names the hook that does the work, and
3357
+ // the nesting IS the name: this is `step.contacts.sync`, the string a
3358
+ // workflow document stores. Klaviyo and Attentive declare the same type —
3359
+ // a step belongs to the capability, not to whoever implements it — and the
3360
+ // connection on the step document is what says which vendor runs.
3361
+ sync : ({ data }) => ({
3362
+
3363
+ hook : 'contacts.sync',
3364
+
3365
+ // NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo. Mailchimp's
3366
+ // auth.connect deliberately stores only the data centre (a test pins
3367
+ // that), and settings.audience is an opaque list id no merchant would
3368
+ // recognise in a builder label — so the label names the vendor rather
3369
+ // than showing a string like a1b2c3d4e5.
3370
+ key : 'Sync contact to Mailchimp',
3371
+
3372
+ queue : 'connection',
3373
+
3374
+ // Nothing for a merchant to configure on the step itself — the audience
3375
+ // is chosen once on the connection. Declared empty rather than omitted,
3376
+ // so "this step takes no settings" and "nobody thought about settings"
3377
+ // stay different statements.
3378
+ settings : {},
3379
+
3380
+ // BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
3381
+ // ever fires for someone with no history yet, and crossing into a
3382
+ // segment is the other moment a contact is worth pushing.
3383
+ triggers : [ 'lead.insert', 'segment.contact.add' ],
3384
+
3385
+ // One source for cost: what the builder discloses before a merchant
3386
+ // adds this step, and what is charged when it runs.
3387
+ usage : { actions : 1 }
3388
+
3389
+ })
3390
+
2185
3391
  }
2186
- ],
3392
+
3393
+ },
3394
+ // WHY, in the merchant's words, and what to do about it.
3395
+ tasks : ( data ) => ( data?.settings?.audience
3396
+ ? []
3397
+ : [
3398
+ {
3399
+ message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
3400
+ title : 'Choose an audience'
3401
+ }
3402
+ ]
3403
+ ),
2187
3404
  title : 'Mailchimp'
2188
3405
  };
2189
3406
 
@@ -2194,24 +3411,39 @@ var mailchimp = {
2194
3411
  // lib/ directly. A bare .svg import would need a bundler loader and force the
2195
3412
  // tests onto dist/, which is a worse trade than one line of wrapper.
2196
3413
  var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2197
- <rect width="500" height="500" fill="white"/>
2198
- <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"/>
2199
- <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"/>
2200
- <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"/>
3414
+ <rect width="500" height="500" fill="#95C049"/>
3415
+ <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"/>
3416
+ <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"/>
2201
3417
  </svg>`;
2202
3418
 
2203
3419
  // THE SHARED HALF OF inbound.verify.
2204
3420
  //
2205
3421
  // A vendor whose scheme is "hash the raw body with a shared secret and compare,
2206
3422
  // constant-time, against a header" declares that shape as `inbound.signature`
2207
- // data on its manifest (algorithm, encoding, the env var naming the secret) and
3423
+ // data on its manifest (algorithm, encoding, the NAME of the credential) and
2208
3424
  // wires this straight in as its hook — Shopify does exactly that.
2209
3425
  //
2210
3426
  // A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
2211
3427
  // rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
2212
3428
  // own inbound.verify instead. That is why verify is a hook and not config: this
2213
3429
  // file covers the common case, not the contract.
2214
- const verifySignature = ({ body, descriptor, headers }) => {
3430
+ const verifySignature = ({ body, descriptor, headers, secret }) => {
3431
+
3432
+ // THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
3433
+ // descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
3434
+ // naming which credential a vendor needs is the manifest's job. Reading it is
3435
+ // not: the value lives encrypted in the `provider` collection, and a package
3436
+ // that reached into process.env for it would force every service to copy the
3437
+ // collection back into its environment at boot before this could work.
3438
+ //
3439
+ // A 500 rather than the 401 below, because a secret we never loaded is our
3440
+ // misconfiguration and must not be indistinguishable in the logs from the
3441
+ // forged request that gets the same door slammed on it.
3442
+ if( ! secret ){
3443
+
3444
+ throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
3445
+
3446
+ }
2215
3447
 
2216
3448
  const provided = headers[ descriptor.headers.signature ];
2217
3449
 
@@ -2221,33 +3453,102 @@ const verifySignature = ({ body, descriptor, headers }) => {
2221
3453
 
2222
3454
  }
2223
3455
 
2224
- const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
2225
- .update( body )
2226
- .digest( descriptor.signature.encoding );
3456
+ const digest = createHmac( descriptor.signature.algorithm, secret )
3457
+ .update( body )
3458
+ .digest( descriptor.signature.encoding );
3459
+
3460
+ // Buffers of different lengths crash timingSafeEqual rather than compare
3461
+ // false — decided here, before the length itself becomes a timing signal.
3462
+ const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
3463
+ const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
3464
+
3465
+ if(
3466
+ digestBuffer.length !== providedBuffer.length ||
3467
+ ! timingSafeEqual( digestBuffer, providedBuffer )
3468
+ ){
3469
+
3470
+ throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
3471
+
3472
+ }
3473
+
3474
+ // The proof and the parse happen together on purpose. Nothing downstream of
3475
+ // inbound.verify ever sees the raw bytes, which is what makes acting on
3476
+ // unverified data impossible rather than merely discouraged.
3477
+ return JSON.parse( body.toString() );
3478
+
3479
+ };
3480
+
3481
+ // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
3482
+ const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
3483
+
3484
+ // ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
3485
+ //
3486
+ // Orders are attributed via `_drwbrdg_*` line-item properties injected at
3487
+ // add-to-cart (NOT url params); only items tagged with `_drwbrdg_ca` count
3488
+ // toward a conversion's attributed gross. This is Shopify's payload shape, so
3489
+ // it lives with the vendor — it was drawbridge-sync's lib/attribution.js.
3490
+
3491
+ const toLine = ({
3492
+ price,
3493
+ product_id : productId,
3494
+ quantity,
3495
+ title,
3496
+ variant_id : variantId,
3497
+ variant_title : variantTitle
3498
+ }) => ({
3499
+ price : parseFloat( price ) || 0,
3500
+ productId : productId ? 'gid://shopify/Product/' + productId : null,
3501
+ quantity : quantity || 1,
3502
+ title : title || null,
3503
+ variantId : variantId ? 'gid://shopify/ProductVariant/' + variantId : null,
3504
+ variantTitle : variantTitle || null
3505
+ });
3506
+
3507
+ // Reduce an order's line items to the campaign attribution: the first tagged
3508
+ // item's property map wins for ids (attrMap), gross/lines accumulate across
3509
+ // every tagged item. Untagged items contribute nothing.
3510
+ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
3511
+ ( acc, item ) => {
3512
+
3513
+ const attrs = ( item.properties || [] ).reduce(
3514
+ ( map, { name, value } ) => {
3515
+
3516
+ map[ name ] = value;
3517
+
3518
+ return map;
3519
+
3520
+ },
3521
+ {}
3522
+ );
3523
+
3524
+ if( ! attrs[ '_drwbrdg_ca' ] ) return acc;
3525
+
3526
+ if( ! Object.keys( acc.attrMap ).length ) acc.attrMap = attrs;
3527
+
3528
+ const line = toLine( item );
2227
3529
 
2228
- // Buffers of different lengths crash timingSafeEqual rather than compare
2229
- // false — decided here, before the length itself becomes a timing signal.
2230
- const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
2231
- const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
3530
+ acc.attributedGross += line.price * line.quantity;
3531
+ acc.attributedLines.push( line );
2232
3532
 
2233
- if(
2234
- digestBuffer.length !== providedBuffer.length ||
2235
- ! timingSafeEqual( digestBuffer, providedBuffer )
2236
- ){
3533
+ return acc;
2237
3534
 
2238
- throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
3535
+ },
3536
+ { attrMap : {}, attributedGross : 0, attributedLines : [] }
3537
+ );
2239
3538
 
2240
- }
3539
+ // Unambiguous alphabet and length, because a merchant reads these aloud and
3540
+ // types them into a checkout.
3541
+ const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
2241
3542
 
2242
- // The proof and the parse happen together on purpose. Nothing downstream of
2243
- // inbound.verify ever sees the raw bytes, which is what makes acting on
2244
- // unverified data impossible rather than merely discouraged.
2245
- return JSON.parse( body.toString() );
3543
+ // Rotate before the window closes rather than at it: Shopify's refresh token has
3544
+ // an expiry, and a rotation attempted after it has passed cannot succeed.
3545
+ const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
2246
3546
 
2247
- };
3547
+ const OAUTH_ERROR_SOURCE = 'oauth';
2248
3548
 
2249
- // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
2250
- const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
3549
+ // What Shopify says when the merchant has uninstalled or revoked. Neither is
3550
+ // retryable and both mean the same thing to a merchant: reconnect.
3551
+ const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
2251
3552
 
2252
3553
  // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
2253
3554
  // about someone else's product, so they belong beside the rest of the vendor
@@ -2334,7 +3635,7 @@ var shopify = {
2334
3635
  // the App Store listing, and the dashboard must never imply a store can be
2335
3636
  // linked from inside it.
2336
3637
  redirect : {
2337
- env : 'SHOPIFY_APP_LISTING_URL',
3638
+ credential : 'SHOPIFY_APP_LISTING_URL',
2338
3639
  title : 'View on the Shopify App Store'
2339
3640
  }
2340
3641
  },
@@ -2391,20 +3692,670 @@ var shopify = {
2391
3692
  //
2392
3693
  // `shopify` is injected for the same reason it is everywhere else — this
2393
3694
  // package cannot import @drawbridge/shopify, which depends on it.
2394
- scopes : ({ scope, shopify }) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
3695
+ scopes : ( { scope }, { shopify } = {} ) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
2395
3696
  // Shopify's install grant is exchanged inside its own app flow, not
2396
3697
  // through the shared OAuth runner.
2397
3698
  token : false
2398
3699
  },
2399
- // Implemented in drawbridge-sync, which owns the attribution and the
2400
- // controllers it needs. Declared here so the steps below can point at them:
2401
- // a step naming a hook the vendor does not implement is a workflow that
2402
- // accepts the step and then silently does nothing.
3700
+ // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
3701
+ // enqueues and events for the shell to perform see contract.js and
3702
+ // everything it needs arrives as an argument: `read` (the controller's
3703
+ // read methods, nothing that writes), `shopify` (the SDK, injected because
3704
+ // this package cannot import what depends on it), `adminToken` (minted by
3705
+ // the shell, which persists rotations), `mintId` (so one described write
3706
+ // can reference another), `dispatch` (the caller's own coordinator table,
3707
+ // for the hooks that are dispatches).
2403
3708
  commerce : {
2404
- code : {},
2405
- customer : {},
2406
- order : {},
2407
- product : {}
3709
+
3710
+ // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
3711
+ // one lead — which is what lets an order that redeems it be attributed
3712
+ // back.
3713
+ code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
3714
+
3715
+ const discount = step.settings?.discount;
3716
+
3717
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
3718
+
3719
+ if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
3720
+ if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
3721
+ if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
3722
+
3723
+ const adminAccessToken = await adminToken();
3724
+
3725
+ // The customer must exist before a code is mapped to them. An earlier
3726
+ // commerce.customer step usually did this and left the id on the
3727
+ // context; when this step runs alone, it does it here.
3728
+ if( ! context.shopifyCustomerId ){
3729
+
3730
+ const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
3731
+
3732
+ if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
3733
+
3734
+ }
3735
+
3736
+ const discountCode = await shopify.admin.createDiscountCode({
3737
+ adminAccessToken,
3738
+ code : 'DB-' + generateDiscountCode(),
3739
+ discountId : discount.id,
3740
+ domain : connection.shop
3741
+ });
3742
+
3743
+ if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
3744
+
3745
+ return {
3746
+ context : {
3747
+ shopifyDiscountCode : discountCode.code,
3748
+ shopifyDiscountId : String( discountCode.id )
3749
+ },
3750
+ message : 'Discount code created and linked to lead.',
3751
+ request,
3752
+ response : { code : discountCode.code, id : String( discountCode.id ) },
3753
+ // bypassDocumentValidation because these are vendor ids on a
3754
+ // Drawbridge document the schema does not declare — the
3755
+ // canonical-identity work resolves it properly.
3756
+ writes : [ {
3757
+ collection : 'lead',
3758
+ data : {
3759
+ $set : {
3760
+ shopifyDiscountCode : discountCode.code,
3761
+ shopifyDiscountId : String( discountCode.id )
3762
+ }
3763
+ },
3764
+ operation : 'update',
3765
+ options : { bypassDocumentValidation : true },
3766
+ query : { id : context.lead }
3767
+ } ]
3768
+ };
3769
+
3770
+ },
3771
+
3772
+ // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
3773
+ //
3774
+ // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
3775
+ // customer at the store is a support ticket: the context may already
3776
+ // carry the id from an earlier step, the lead may already be linked from
3777
+ // an earlier run, and Shopify's own get-or-create settles the rest.
3778
+ customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
3779
+
3780
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
3781
+
3782
+ if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
3783
+ if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
3784
+
3785
+ // Already known from an earlier step in this run.
3786
+ if( context.shopifyCustomerId ){
3787
+
3788
+ return {
3789
+ context : { shopifyCustomerId : context.shopifyCustomerId },
3790
+ message : 'Trigger data already includes a Shopify customer id — reusing.',
3791
+ request,
3792
+ response : { shopifyCustomerId : context.shopifyCustomerId },
3793
+ // Reusing an id is not a creation, so it does not bill.
3794
+ skipped : true
3795
+ };
3796
+
3797
+ }
3798
+
3799
+ const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
3800
+
3801
+ // Already linked by an earlier run.
3802
+ if( lead?.shopifyCustomerId ){
3803
+
3804
+ return {
3805
+ context : { shopifyCustomerId : lead.shopifyCustomerId },
3806
+ message : 'Lead already has a Shopify customer id — reusing.',
3807
+ request,
3808
+ response : { shopifyCustomerId : lead.shopifyCustomerId },
3809
+ skipped : true
3810
+ };
3811
+
3812
+ }
3813
+
3814
+ const adminAccessToken = await adminToken();
3815
+
3816
+ // Shopify keeps first and last separately; Drawbridge keeps one name.
3817
+ // Split on the first space and give everything after it to the
3818
+ // surname, which is wrong for some names and is what the vendor's
3819
+ // shape allows.
3820
+ const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
3821
+
3822
+ const customer = await shopify.admin.getOrCreateCustomer({
3823
+ adminAccessToken,
3824
+ domain : connection.shop,
3825
+ email : context.email,
3826
+ firstName : parts.length ? parts[ 0 ] : null,
3827
+ lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
3828
+ source : 'drawbridge'
3829
+ });
3830
+
3831
+ if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
3832
+
3833
+ return {
3834
+ context : { shopifyCustomerId : customer.id },
3835
+ message : 'Shopify customer created/linked to lead.',
3836
+ request,
3837
+ response : { shopifyCustomerId : customer.id },
3838
+ // The hook's own result, described beside the call that produced it.
3839
+ writes : [ {
3840
+ collection : 'lead',
3841
+ data : { $set : { shopifyCustomerId : customer.id } },
3842
+ operation : 'update',
3843
+ options : { bypassDocumentValidation : true },
3844
+ query : { id : context.lead }
3845
+ } ]
3846
+ };
3847
+
3848
+ },
3849
+
3850
+ // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
3851
+ // attribution genuinely is: an order can reach Drawbridge two ways and
3852
+ // they bill differently.
3853
+ //
3854
+ // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
3855
+ // add-to-cart. Causal: the campaign produced the sale, so
3856
+ // it carries a fee.
3857
+ // REDEMPTION — a DB- discount code matched to a lead. Associative: we
3858
+ // cannot claim we caused the purchase, so it is fee-free.
3859
+ //
3860
+ // Both can be true, and an order already recorded as a conversion can
3861
+ // later have a redemption backfilled onto it — `backfill` below.
3862
+ //
3863
+ // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
3864
+ // order race, the loser's transaction hits a duplicate key, the step
3865
+ // fails and BullMQ redelivers — and the re-run's read at the top finds
3866
+ // what the winner wrote and skips instead of double-billing a merchant
3867
+ // for one purchase. The hook used to loop for this itself; describing
3868
+ // the writes moved the retry to the queue, with the same guarantee.
3869
+ order : async ( { connection, context }, { logger, mintId, read } = {} ) => {
3870
+
3871
+ const {
3872
+ advertisement,
3873
+ created_at : createdAt,
3874
+ currency,
3875
+ customer : orderCustomer,
3876
+ email,
3877
+ id : orderId,
3878
+ line_items : lineItems = [],
3879
+ organization,
3880
+ phone
3881
+ } = context || {};
3882
+
3883
+ const request = { orderId : orderId ? String( orderId ) : null, organization };
3884
+
3885
+ const [ existingOrder, existingRedemption ] = await Promise.all([
3886
+ read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
3887
+ read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
3888
+ ]);
3889
+
3890
+ // Already fully recorded. This is the branch the redelivery exists to
3891
+ // reach.
3892
+ if( existingRedemption ){
3893
+
3894
+ return {
3895
+ message : 'Order/redemption already recorded — skipping duplicate.',
3896
+ request,
3897
+ response : {
3898
+ existingOrderId : existingOrder?.id || null,
3899
+ existingRedemptionId : existingRedemption.id,
3900
+ skipped : true
3901
+ },
3902
+ skipped : true
3903
+ };
3904
+
3905
+ }
3906
+
3907
+ const backfill = ! ! existingOrder;
3908
+
3909
+ // ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
3910
+ const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
3911
+
3912
+ const campaign = attrMap[ '_drwbrdg_ca' ] || null;
3913
+
3914
+ const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
3915
+ const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
3916
+
3917
+ const matchedLeads = codes.length
3918
+ ? await read.aggregate({
3919
+ collection : 'lead',
3920
+ pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
3921
+ })
3922
+ : [];
3923
+
3924
+ const codeToLead = {};
3925
+
3926
+ for( const found of matchedLeads ){
3927
+
3928
+ if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
3929
+
3930
+ }
3931
+
3932
+ const matchedDiscounts = discountCodes
3933
+ .filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
3934
+ .map( ( dc ) => ({
3935
+ amount : parseFloat( dc.amount ) || 0,
3936
+ code : dc.code,
3937
+ id : codeToLead[ dc.code ].shopifyDiscountId || null
3938
+ }) );
3939
+
3940
+ const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
3941
+
3942
+ const discount = matchedDiscounts.length
3943
+ ? {
3944
+ amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
3945
+ codes : matchedDiscounts
3946
+ }
3947
+ : null;
3948
+
3949
+ const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
3950
+
3951
+ // A DB- code we minted that matched no lead. Logged rather than
3952
+ // ignored: it means a code went out and its lead link was lost, which
3953
+ // is revenue we cannot attribute and nobody would otherwise notice.
3954
+ const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
3955
+
3956
+ if( unmatched.length ){
3957
+
3958
+ logger?.warn?.( 'shopify.order.discount.unmatched', {
3959
+ campaign : campaign || null,
3960
+ codes : JSON.stringify( unmatched ),
3961
+ isConversion : ! ! campaign,
3962
+ orderId : String( orderId ),
3963
+ organization
3964
+ });
3965
+
3966
+ }
3967
+
3968
+ if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
3969
+
3970
+ return {
3971
+ message : backfill
3972
+ ? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
3973
+ : 'Order has no Drawbridge attribution — not recording.',
3974
+ request,
3975
+ response : { skipped : true },
3976
+ skipped : true
3977
+ };
3978
+
3979
+ }
3980
+
3981
+ let advertisementId = null;
3982
+ let affiliateId = null;
3983
+ let campaignOrganization = organization;
3984
+ let gross = 0;
3985
+ let leadId = null;
3986
+ let lines = [];
3987
+ let orderCampaign = null;
3988
+ let pageId = null;
3989
+
3990
+ const isConversion = ! ! campaign;
3991
+
3992
+ const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
3993
+
3994
+ const matchPhones = [ ...new Set([
3995
+ customerPhone,
3996
+ toE164( context?.billing_address?.phone ),
3997
+ toE164( context?.shipping_address?.phone )
3998
+ ].filter( Boolean ) ) ];
3999
+
4000
+ if( isConversion ){
4001
+
4002
+ const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
4003
+
4004
+ // THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
4005
+ //
4006
+ // `_drwbrdg_ca` is a line-item property, and on most Shopify themes
4007
+ // a buyer can attach arbitrary line-item properties via cart
4008
+ // permalinks or the AJAX cart API — and campaign ids are
4009
+ // discoverable from public campaign links. Without this check, a $1
4010
+ // order on ANY connected store carrying another org's campaign id
4011
+ // records a conversion under that org: its revenue totals climb,
4012
+ // its usage document is incremented, and its matching leads gain
4013
+ // order counts — a cross-tenant write driven entirely by the buyer.
4014
+ //
4015
+ // A stale or garbage id lands here too, so this is also the null
4016
+ // check: either way the order simply has no Drawbridge attribution.
4017
+ if( ! campaignDoc || campaignDoc.organization !== organization ){
4018
+
4019
+ return {
4020
+ message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
4021
+ request,
4022
+ response : { skipped : true },
4023
+ skipped : true
4024
+ };
4025
+
4026
+ }
4027
+
4028
+ advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
4029
+ affiliateId = attrMap[ '_drwbrdg_af' ] || null;
4030
+ campaignOrganization = campaignDoc.organization;
4031
+ gross = attributedGross;
4032
+ lines = attributedLines;
4033
+ orderCampaign = campaign;
4034
+ pageId = attrMap[ '_drwbrdg_pg' ] || null;
4035
+
4036
+ // MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
4037
+ // because the address on an order is often not the one they entered
4038
+ // with.
4039
+ const identifiers = [];
4040
+
4041
+ const canonicalEmail = toCanonicalEmail( email );
4042
+
4043
+ if( email ) identifiers.push({ email : email.toLowerCase() });
4044
+ if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
4045
+ if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
4046
+ if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
4047
+
4048
+ if( identifiers.length ){
4049
+
4050
+ // Narrowed to the campaign first; an org-wide match is the
4051
+ // fallback, because a buyer who entered a different campaign is
4052
+ // still the same person and still worth linking.
4053
+ const lead = await read.get({
4054
+ collection : 'lead',
4055
+ query : {
4056
+ campaigns : { $in : [ campaign ] },
4057
+ organization : campaignOrganization,
4058
+ $or : identifiers
4059
+ }
4060
+ });
4061
+
4062
+ leadId = lead?.id || null;
4063
+
4064
+ if( ! leadId ){
4065
+
4066
+ const orgLead = await read.get({
4067
+ collection : 'lead',
4068
+ query : { organization : campaignOrganization, $or : identifiers }
4069
+ });
4070
+
4071
+ leadId = orgLead?.id || null;
4072
+
4073
+ }
4074
+
4075
+ }
4076
+
4077
+ } else {
4078
+
4079
+ // REDEMPTION. The lead is known from the code, and the whole order
4080
+ // counts as gross — there are no tagged lines to narrow it to.
4081
+ leadId = matchedLead.id;
4082
+ orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
4083
+ gross = lineItems.reduce( ( sum, item ) => {
4084
+
4085
+ const line = toLine( item );
4086
+
4087
+ return sum + ( line.price * line.quantity );
4088
+
4089
+ }, 0 );
4090
+ lines = lineItems.map( toLine );
4091
+
4092
+ }
4093
+
4094
+ const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
4095
+
4096
+ let rate = 0;
4097
+
4098
+ // THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
4099
+ // redemption is associative — we cannot claim we caused the purchase —
4100
+ // so it is recorded fee-free.
4101
+ if( isConversion ){
4102
+
4103
+ const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
4104
+
4105
+ rate = conversionRate( subscription );
4106
+
4107
+ }
4108
+
4109
+ const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
4110
+ const net = Math.round( ( gross - fee ) * 100 ) / 100;
4111
+
4112
+ const currencyCode = ( currency || 'usd' ).toLowerCase();
4113
+ const purchasedAt = new Date( createdAt || Date.now() );
4114
+
4115
+ const customer = ( orderCustomer || email || phone )
4116
+ ? {
4117
+ acceptsMarketing : orderCustomer?.email_marketing_consent?.state
4118
+ ? orderCustomer.email_marketing_consent.state === 'subscribed'
4119
+ : ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
4120
+ email : orderCustomer?.email || email || null,
4121
+ firstName : orderCustomer?.first_name || null,
4122
+ id : orderCustomer?.id ? String( orderCustomer.id ) : null,
4123
+ lastName : orderCustomer?.last_name || null,
4124
+ phone : customerPhone
4125
+ }
4126
+ : null;
4127
+
4128
+ const source = connection?.source
4129
+ ? { domain : connection.source.domain, id : connection.source.id }
4130
+ : undefined;
4131
+
4132
+ // MINTED HERE, because the redemption names its order and the usage
4133
+ // job names both — a description cannot read a write's result, so the
4134
+ // id exists before either does.
4135
+ const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
4136
+
4137
+ const writes = [];
4138
+
4139
+ if( isConversion && ! backfill ){
4140
+
4141
+ writes.push({
4142
+ collection : 'order',
4143
+ data : {
4144
+ advertisement : advertisementId,
4145
+ affiliate : affiliateId,
4146
+ campaign : orderCampaign,
4147
+ currency : currencyCode,
4148
+ customer,
4149
+ discount,
4150
+ fee,
4151
+ gross,
4152
+ id : orderDocId,
4153
+ lead : leadId,
4154
+ lines,
4155
+ net,
4156
+ organization : campaignOrganization,
4157
+ page : pageId,
4158
+ provider : { id : String( orderId ), slug : 'shopify' },
4159
+ purchasedAt,
4160
+ rate,
4161
+ source,
4162
+ status : 'completed'
4163
+ },
4164
+ operation : 'create'
4165
+ });
4166
+
4167
+ if( org?.usage ){
4168
+
4169
+ writes.push({
4170
+ collection : 'usage',
4171
+ data : { $inc : { 'totals.revenue' : gross } },
4172
+ operation : 'update',
4173
+ query : { id : org.usage }
4174
+ });
4175
+
4176
+ }
4177
+
4178
+ if( leadId ){
4179
+
4180
+ writes.push({
4181
+ collection : 'lead',
4182
+ data : { $inc : { 'totals.orders' : 1 } },
4183
+ operation : 'update',
4184
+ options : { bypassDocumentValidation : true },
4185
+ query : { id : leadId }
4186
+ });
4187
+
4188
+ }
4189
+
4190
+ }
4191
+
4192
+ if( discount ){
4193
+
4194
+ writes.push({
4195
+ collection : 'redemption',
4196
+ data : {
4197
+ advertisement : advertisementId,
4198
+ affiliate : affiliateId,
4199
+ campaign : orderCampaign,
4200
+ code : matchedDiscounts[ 0 ]?.code || null,
4201
+ currency : currencyCode,
4202
+ customer,
4203
+ discount,
4204
+ gross,
4205
+ lead : leadId,
4206
+ order : orderDocId,
4207
+ organization : campaignOrganization,
4208
+ page : pageId,
4209
+ provider : { id : String( orderId ), slug : 'shopify' },
4210
+ purchasedAt,
4211
+ source,
4212
+ status : 'completed'
4213
+ },
4214
+ operation : 'create'
4215
+ });
4216
+
4217
+ if( org?.usage ){
4218
+
4219
+ writes.push({
4220
+ collection : 'usage',
4221
+ data : { $inc : { 'totals.redemptions' : 1 } },
4222
+ operation : 'update',
4223
+ query : { id : org.usage }
4224
+ });
4225
+
4226
+ }
4227
+
4228
+ if( leadId ){
4229
+
4230
+ writes.push({
4231
+ collection : 'lead',
4232
+ data : { $inc : { 'totals.redemptions' : 1 } },
4233
+ operation : 'update',
4234
+ options : { bypassDocumentValidation : true },
4235
+ query : { id : leadId }
4236
+ });
4237
+
4238
+ }
4239
+
4240
+ }
4241
+
4242
+ // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
4243
+ // id so a redelivery cannot charge twice. Never on a backfill: the fee
4244
+ // was charged when the order was first recorded. Enqueues run after
4245
+ // the transaction commits, so the job can never observe rows that
4246
+ // roll back.
4247
+ const enqueues = ( org?.billingProvider === 'shopify' && fee > 0 && connection?.source?.id && ! backfill )
4248
+ ? [ {
4249
+ data : {
4250
+ idempotencyKey : String( orderId ),
4251
+ orderDocId,
4252
+ orderId : String( orderId ),
4253
+ rate,
4254
+ shopId : connection.source.id,
4255
+ // The App Events API returns no event id, so one is generated
4256
+ // here — the event handle plus the order id — and sent as the
4257
+ // event's `reference`. queue/usage.js stamps the same id onto
4258
+ // the order as billed.transaction.
4259
+ transaction : 'drawbridge-orders.' + orderId,
4260
+ value : Math.round( fee * 100 )
4261
+ },
4262
+ name : 'billing',
4263
+ options : { jobId : 'shopify.usage.' + orderId },
4264
+ queue : 'usage'
4265
+ } ]
4266
+ : [];
4267
+
4268
+ return {
4269
+ enqueues,
4270
+ message : backfill
4271
+ ? 'Redemption backfilled for an already-recorded order.'
4272
+ : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
4273
+ request,
4274
+ response : {
4275
+ campaign : orderCampaign,
4276
+ currency : currencyCode,
4277
+ discount,
4278
+ fee,
4279
+ gross,
4280
+ lead : leadId,
4281
+ lines : lines.length,
4282
+ net,
4283
+ orderId : String( orderId )
4284
+ },
4285
+ // ONE TRANSACTION. The order, the redemption and both totals
4286
+ // counters land together or not at all — a half-written attribution
4287
+ // is revenue counted twice or not at all, and neither is
4288
+ // recoverable by hand.
4289
+ transaction : writes.length > 0,
4290
+ writes
4291
+ };
4292
+
4293
+ },
4294
+
4295
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
4296
+ // the product pipeline; the actual field sync happens there.
4297
+ //
4298
+ // The shell has already refused a missing or inactive Shopify connection,
4299
+ // so what is left is the two things only this hook can know are wrong.
4300
+ product : async ( { connection, context, workflow }, { mintId } = {} ) => {
4301
+
4302
+ const request = {
4303
+ numericId : context?.id || null,
4304
+ organizationId : workflow.organization,
4305
+ title : context?.title || null
4306
+ };
4307
+
4308
+ if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
4309
+
4310
+ // The shop domain is half the identity below. Without it the upsert
4311
+ // would match on provider id alone and could collide across stores.
4312
+ if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
4313
+
4314
+ const providerId = 'gid://shopify/Product/' + context.id;
4315
+
4316
+ // Minted so the enqueue can name the row this upsert makes — and the
4317
+ // job carries the PROVIDER identity too, because under a concurrent
4318
+ // redelivery this id may be the one that lost the upsert race. The
4319
+ // worker falls back to provider + shop, which are stable either way.
4320
+ const productId = mintId();
4321
+
4322
+ return {
4323
+ enqueues : [ {
4324
+ data : { product : productId, providerId, shop : connection.shop },
4325
+ name : 'workflow',
4326
+ options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
4327
+ queue : 'product.shopify'
4328
+ } ],
4329
+ message : 'Product sync queued from Shopify webhook.',
4330
+ request,
4331
+ response : { productId, providerId, title : context?.title || null },
4332
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
4333
+ // two rows. `connections` accumulates rather than replaces: one
4334
+ // store can be linked to several organizations, and each keeps its
4335
+ // own claim on the row.
4336
+ writes : [ {
4337
+ collection : 'product',
4338
+ data : {
4339
+ $addToSet : { connections : connection.id },
4340
+ $setOnInsert : {
4341
+ id : productId,
4342
+ provider : { id : providerId, slug : 'shopify' },
4343
+ 'source.id' : connection.id,
4344
+ status : 'active'
4345
+ }
4346
+ },
4347
+ operation : 'update',
4348
+ options : { upsert : true },
4349
+ query : {
4350
+ 'provider.id' : providerId,
4351
+ 'provider.slug' : 'shopify',
4352
+ 'source.domain' : connection.shop
4353
+ }
4354
+ } ]
4355
+ };
4356
+
4357
+ }
4358
+
2408
4359
  },
2409
4360
  contacts : { remove : false, sync : false },
2410
4361
 
@@ -2423,8 +4374,22 @@ var shopify = {
2423
4374
  sms : false,
2424
4375
  inbound : {
2425
4376
  event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
2426
- process : {},
2427
- receive : ({ channel, event, headers, payload }) => {
4377
+ // One hook over the whole topic table, because that is what this
4378
+ // manifest declares: Shopify processes its own buffered events. The
4379
+ // topic rides in on the context rather than being a second hook name per
4380
+ // topic; the caller's handler table arrives as a prop.
4381
+ process : async ( { context }, { dispatch } = {} ) => {
4382
+
4383
+ const key = 'shopify.' + context?.topic;
4384
+
4385
+ const handled = await dispatch({ data : context?.data, handler : key });
4386
+
4387
+ if( ! handled ) return { message : 'No handler for ' + key, skipped : true };
4388
+
4389
+ return { message : 'Processed ' + key, request : { topic : context?.topic } };
4390
+
4391
+ },
4392
+ receive : ( { channel, event, headers, payload } ) => {
2428
4393
 
2429
4394
  if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
2430
4395
 
@@ -2446,7 +4411,173 @@ var shopify = {
2446
4411
  },
2447
4412
  verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
2448
4413
  },
2449
- lifecycle : { cleanup : {}, health : {}, register : {}, rehydrate : {} },
4414
+ lifecycle : {
4415
+
4416
+ // DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
4417
+ // declarations made true: the work is queue orchestration over
4418
+ // Drawbridge's own collections, which is coordinator work and stays in
4419
+ // the repo that owns the queues. The hook receives the dispatch table as
4420
+ // a prop and picks the entry, so the manifest owns the SEAM — asking
4421
+ // Shopify whether it handles its own lifecycle now gets a real function
4422
+ // instead of `unimplemented` while the work happened anyway.
4423
+ cleanup : async ( { context }, { dispatch } = {} ) => {
4424
+
4425
+ await dispatch({ data : context, handler : 'cleanup' });
4426
+
4427
+ return { message : 'Ran shopify lifecycle.cleanup', request : context || null };
4428
+
4429
+ },
4430
+
4431
+ // KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
4432
+ // step once carried — webhooks are declarative, declared in the app's
4433
+ // toml and applied by Shopify to every install, so nothing here registers
4434
+ // or checks them.
4435
+ //
4436
+ // It rotates the refresh token before its window closes, proves the
4437
+ // access token still works, reconciles the scopes the store granted
4438
+ // against the ones the app now needs, and queues a webhook
4439
+ // reconciliation.
4440
+ health : async ( { connection, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
4441
+
4442
+ const request = {
4443
+ connectionId : workflow.connection,
4444
+ organizationId : workflow.organization,
4445
+ shop : connection.shop
4446
+ };
4447
+
4448
+ // Captured BEFORE anything runs. If the calls below fail with
4449
+ // invalid_grant, this is what distinguishes "the merchant revoked us"
4450
+ // from "a concurrent rotation spent the token we were holding" — and
4451
+ // only the first should error the connection.
4452
+ const refreshTokenAtStart = ( await resolveSettings() ).refreshToken || null;
4453
+
4454
+ try {
4455
+
4456
+ const adminAccessToken = await adminToken();
4457
+
4458
+ const settings = await resolveSettings();
4459
+
4460
+ const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
4461
+
4462
+ const needsRotation = refreshTokenExpiresAt
4463
+ && new Date( refreshTokenExpiresAt ) < new Date( Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS );
4464
+
4465
+ let refreshTokenRotated = false;
4466
+
4467
+ if( needsRotation ){
4468
+
4469
+ await rotateToken();
4470
+
4471
+ refreshTokenRotated = true;
4472
+
4473
+ }
4474
+
4475
+ await shopify.oauth.ping({ adminAccessToken, domain : connection.shop });
4476
+
4477
+ // SCOPE DRIFT IS ITS OWN FAILURE. A token can be perfectly valid
4478
+ // while the grant is too narrow, which no ping would ever reveal.
4479
+ // Injected rather than described: reconciliation is the caller's
4480
+ // writer-of-record routine, shared with the webhook drain, and this
4481
+ // hook needs its RESULT to compose the message below.
4482
+ const scopesMissing = await reconcileScopes({ shop : connection.shop });
4483
+
4484
+ return {
4485
+ enqueues : [ {
4486
+ data : {
4487
+ data : {
4488
+ connectionId : workflow.connection,
4489
+ organizationId : workflow.organization
4490
+ },
4491
+ event : 'shopify.register.webhooks'
4492
+ },
4493
+ name : 'register',
4494
+ options : { jobId : 'connection.update.register.' + workflow.connection + '.' + randomUUID() },
4495
+ queue : 'connection'
4496
+ } ],
4497
+ message : scopesMissing?.length
4498
+ ? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
4499
+ : refreshTokenRotated
4500
+ ? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
4501
+ : 'Health check passed — ping ok, webhooks reconciled.',
4502
+ request,
4503
+ response : {
4504
+ pingedAt : new Date(),
4505
+ refreshTokenExpiresAt : refreshTokenExpiresAt || null,
4506
+ refreshTokenRotated,
4507
+ scopesMissing,
4508
+ webhookReconciliationQueued : true
4509
+ }
4510
+ };
4511
+
4512
+ } catch ( error ){
4513
+
4514
+ // A REVOKED GRANT IS THE MERCHANT'S TO FIX, so the connection says
4515
+ // so rather than failing silently on a schedule nobody watches. The
4516
+ // write rides OUT ON THE REJECTION — the contract performs a thrown
4517
+ // error's effects — because the step must still fail.
4518
+ if( OAUTH_GRANT_REVOKED_CODES.includes( error.code ) ){
4519
+
4520
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
4521
+
4522
+ // Against the FRESH doc, not the one this run started with — a legacy
4523
+ // connection keeps its tokens in its own settings blob, and a
4524
+ // concurrent rotation rewrote that blob after our snapshot.
4525
+ const refreshTokenStored = current ? ( await resolveSettings( current ) ).refreshToken || null : null;
4526
+
4527
+ // A CONCURRENT ROTATION, not a revocation: another job spent the
4528
+ // refresh token between our read and our use of it. Erroring the
4529
+ // connection here would disconnect a store that is working fine.
4530
+ const rotated = error.code === 'invalid_grant' && refreshTokenStored !== refreshTokenAtStart;
4531
+
4532
+ if( current && ! rotated ){
4533
+
4534
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== OAUTH_ERROR_SOURCE );
4535
+
4536
+ error.writes = [ {
4537
+ collection : 'connection',
4538
+ data : {
4539
+ $set : {
4540
+ errors : [
4541
+ ...others,
4542
+ {
4543
+ message : 'Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.',
4544
+ source : OAUTH_ERROR_SOURCE
4545
+ }
4546
+ ],
4547
+ status : 'error'
4548
+ }
4549
+ },
4550
+ operation : 'update',
4551
+ query : { id : connection.id }
4552
+ } ];
4553
+
4554
+ }
4555
+
4556
+ }
4557
+
4558
+ throw error;
4559
+
4560
+ }
4561
+
4562
+ },
4563
+
4564
+ register : async ( { context }, { dispatch } = {} ) => {
4565
+
4566
+ await dispatch({ data : context, handler : 'register' });
4567
+
4568
+ return { message : 'Ran shopify lifecycle.register', request : context || null };
4569
+
4570
+ },
4571
+
4572
+ rehydrate : async ( { context }, { dispatch } = {} ) => {
4573
+
4574
+ await dispatch({ data : context, handler : 'rehydrate' });
4575
+
4576
+ return { message : 'Ran shopify lifecycle.rehydrate', request : context || null };
4577
+
4578
+ }
4579
+
4580
+ },
2450
4581
  resources : {
2451
4582
  audiences : false,
2452
4583
  // Shopify has no separate price resource — a price belongs to a product
@@ -2467,7 +4598,7 @@ var shopify = {
2467
4598
  // credential is the caller's job because it is Drawbridge's job: the
2468
4599
  // admin token refreshes and writes itself back, which is service work,
2469
4600
  // not vendor work.
2470
- products : async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
4601
+ products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
2471
4602
 
2472
4603
  const products = await shopify.storefront.getProducts({
2473
4604
  cursor,
@@ -2490,7 +4621,7 @@ var shopify = {
2490
4621
 
2491
4622
  },
2492
4623
 
2493
- promotions : async ({ cursor, limit = 100, search, settings, shopify }) => {
4624
+ promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
2494
4625
 
2495
4626
  const discounts = await shopify.admin.getDiscounts({
2496
4627
  adminAccessToken : settings?.adminAccessToken,
@@ -2541,6 +4672,19 @@ var shopify = {
2541
4672
  : undefined;
2542
4673
 
2543
4674
  },
4675
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
4676
+ // what an admin types on the provider screen. The four names below are exactly
4677
+ // what `requires` gates on, which is the point of declaring them together: a
4678
+ // name required by the manifest and enterable nowhere is a vendor that can
4679
+ // never go live from the admin screen.
4680
+ provider : {
4681
+ fields : [
4682
+ { input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
4683
+ { input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
4684
+ { input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
4685
+ { input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
4686
+ ]
4687
+ },
2544
4688
  // A pre-launch integration: it only surfaces once the App Store listing
2545
4689
  // exists and the app is fully configured. Requiring all four means it can
2546
4690
  // never render half-configured — and absence of any one excludes the
@@ -2574,9 +4718,6 @@ var shopify = {
2574
4718
  // workflow document, and those strings cannot be renamed without a backfill.
2575
4719
  //
2576
4720
  // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2577
- // The bodies these point at still live in drawbridge-sync; moving them is the
2578
- // next unit, and commerce.order.record is the one that decides whether the
2579
- // shape holds — 569 lines and 15 controller calls.
2580
4721
  steps : {
2581
4722
 
2582
4723
  commerce : {
@@ -2785,7 +4926,7 @@ var webhook = {
2785
4926
  // needs Drawbridge's own database, sockets or queues. This one does not.
2786
4927
  webhook : {
2787
4928
 
2788
- send : async ({ context, controller, request : send = safeRequest, settings, step }) => {
4929
+ send : async ( { context, lead, settings, step }, { request : send = safeRequest } = {} ) => {
2789
4930
 
2790
4931
  const { headers = {}, method = 'POST', url } = step.settings || {};
2791
4932
 
@@ -2794,11 +4935,9 @@ var webhook = {
2794
4935
  if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
2795
4936
 
2796
4937
  // The LEAD, when there is one, rather than the accumulated context. A
2797
- // receiver wants the entrant's record, not our internal step state.
2798
- const lead = context?.lead
2799
- ? await controller.get({ collection : 'lead', query : { id : context.lead } })
2800
- : null;
2801
-
4938
+ // receiver wants the entrant's record, not our internal step state
4939
+ // and the shell already resolved it, because every hook that names a
4940
+ // lead gets the document and its consent together.
2802
4941
  const body = lead || context;
2803
4942
 
2804
4943
  request.body = body;
@@ -2864,7 +5003,7 @@ var webhook = {
2864
5003
  // pressing Connect will do; afterwards it states the verification the
2865
5004
  // merchant's own endpoint has to perform, because a signed payload nobody
2866
5005
  // checks is an unsigned payload.
2867
- tasks : ({ settings }) => ( settings?.secret
5006
+ tasks : ( { settings } ) => ( settings?.secret
2868
5007
  ? [
2869
5008
  {
2870
5009
  message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
@@ -3030,6 +5169,39 @@ const build = ( manifest ) => {
3030
5169
 
3031
5170
  }
3032
5171
 
5172
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, if it has any. A connection
5173
+ // with no third party behind it — webhook — declares no block at all, which is
5174
+ // what keeps it off the provider screen.
5175
+ //
5176
+ // Every field is checked harder than a merchant field: it must be editable
5177
+ // (an admin cannot type into a read-only descriptor, and a credential nobody
5178
+ // can enter is a vendor that can never go live), and a `password` MUST be
5179
+ // redacted. That last one is a SECURITY BOUNDARY rather than tidiness —
5180
+ // providerFields is what the api's redaction derives from, so a password field
5181
+ // that forgot `redact : true` is a platform secret handed back over the wire
5182
+ // to every admin screen that asks.
5183
+ for( const field of manifest.provider?.fields || [] ){
5184
+
5185
+ if( ! field?.key || ! field?.label ){
5186
+
5187
+ throw new Error( manifest.slug + ' declares a provider field with no key or label' );
5188
+
5189
+ }
5190
+
5191
+ if( ! INPUTS.includes( field.input ) ){
5192
+
5193
+ throw new Error( manifest.slug + '.provider.' + field.key + ' needs an input the admin form can render — one of ' + INPUTS.join( ', ' ) );
5194
+
5195
+ }
5196
+
5197
+ if( field.input === 'password' && ! field.redact ){
5198
+
5199
+ throw new Error( manifest.slug + '.provider.' + field.key + ' is a password and must declare redact : true — the api would hand the value back' );
5200
+
5201
+ }
5202
+
5203
+ }
5204
+
3033
5205
  // THE ICON RIDES WITH THE MANIFEST, so a vendor cannot name an asset nobody
3034
5206
  // added — which is what the old arrangement allowed, with the markup in one
3035
5207
  // repo and the file in another.
@@ -3324,36 +5496,41 @@ const connections = Object.freeze({
3324
5496
  webhook : build( webhook )
3325
5497
  });
3326
5498
 
3327
- // A step type belongs to exactly one vendor.
5499
+ // A STEP TYPE MAY BE SHARED, BUT NOT ITS QUEUE.
3328
5500
  //
3329
- // This is checked because the current naming HIDES the problem rather than
3330
- // solving it: every commerce step is namespaced by vendor
3331
- // (step.shopify.customer.insert), so a collision is impossible only for as long
3332
- // as that holds. The moment a capability is named for what it does rather than
3333
- // who does it — step.commerce.customer.insert, which is where this should go —
3334
- // two vendors declaring it would silently collapse into one entry in stepQueues
3335
- // and one of them would route nowhere.
5501
+ // A step type belongs to the CAPABILITY rather than to whoever implements it —
5502
+ // that is why step.shopify.* became step.commerce.*, and why Klaviyo, Mailchimp
5503
+ // and Attentive all declare step.contacts.sync. Sharing the name is the intent,
5504
+ // and the vendor is carried on the step DOCUMENT (its connection), not in the
5505
+ // type string.
3336
5506
  //
3337
- // Fail at import instead. When the neutral names land, this is the guard that
3338
- // makes the ambiguity visible on the first line of the first test run.
5507
+ // What must not be shared is the ROUTE. stepQueues is { type : queue }, so two
5508
+ // vendors declaring one type with different queues collapse into a single entry
5509
+ // and whichever loses is enqueued nowhere — a workflow that accepts the step and
5510
+ // silently never runs it. That is the real failure this guard was written for;
5511
+ // the ownership rule was a proxy for it that also refused the case it was
5512
+ // designed to anticipate.
3339
5513
  ( () => {
3340
5514
 
3341
- const owners = {};
5515
+ const routes = {};
3342
5516
 
3343
5517
  for( const [ slug, manifest ] of Object.entries( connections ) ){
3344
5518
 
3345
- for( const [ name ] of leaves( manifest.steps ) ){
5519
+ for( const [ name, step ] of leaves( manifest.steps ) ){
3346
5520
 
3347
5521
  const type = 'step.' + name;
5522
+ const queue = step({})?.queue;
3348
5523
 
5524
+ if( routes[ type ] && routes[ type ].queue !== queue ){
3349
5525
 
3350
- if( owners[ type ] ){
3351
-
3352
- throw new Error( 'Step ' + type + ' is declared by both ' + owners[ type ] + ' and ' + slug );
5526
+ throw new Error(
5527
+ 'Step ' + type + ' routes to ' + routes[ type ].queue + ' for ' + routes[ type ].slug
5528
+ + ' and ' + queue + ' for ' + slug + ' one of them would be enqueued nowhere'
5529
+ );
3353
5530
 
3354
5531
  }
3355
5532
 
3356
- owners[ type ] = slug;
5533
+ routes[ type ] = { queue, slug };
3357
5534
 
3358
5535
  }
3359
5536
 
@@ -3361,10 +5538,13 @@ const connections = Object.freeze({
3361
5538
 
3362
5539
  })();
3363
5540
 
3364
- // The vendors whose environment is actually configured. Every consumer asks this
3365
- // the same way, so "is Shopify available" has one answer rather than one per
3366
- // repo the api gated it on four env vars and sync inferred it from a different
3367
- // signal, which is how the two drifted.
5541
+ // The vendors whose credentials are actually present in the map handed in —
5542
+ // which the callers build from the provider collection, with the deployment's
5543
+ // environment answering only for names no provider field owns
5544
+ // (ENCRYPT_CONNECTION_SECRET). Every consumer asks this the same way, so "is
5545
+ // Shopify available" has one answer rather than one per repo — the api once
5546
+ // gated it on four env vars and sync inferred it from a different signal, which
5547
+ // is how the two drifted.
3368
5548
  const availableConnections = ( env = {} ) => Object.fromEntries(
3369
5549
  Object.entries( connections ).filter(
3370
5550
  ( [ , manifest ] ) => manifest.requires.every( ( name ) => Boolean( env[ name ] ) )
@@ -3460,7 +5640,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
3460
5640
  // The four outcomes are the point. "unsupported", "unimplemented", "failed" and
3461
5641
  // a result are different answers to why there is no data, and collapsing them is
3462
5642
  // how "this vendor cannot do that" becomes indistinguishable from "it broke".
3463
- const runHook = async ( slug, name, args = {} ) => {
5643
+ // TWO OBJECTS: props are facts of the run, options are services being passed
5644
+ // in. A caller with no services to inject omits the second bag.
5645
+ const runHook = async ( slug, name, props = {}, options = {} ) => {
3464
5646
 
3465
5647
  const manifest = connections[ slug ];
3466
5648
 
@@ -3468,7 +5650,17 @@ const runHook = async ( slug, name, args = {} ) => {
3468
5650
 
3469
5651
  // The hook's own value is the answer — there is no `supports` map to consult,
3470
5652
  // and therefore none to disagree with what is actually here.
3471
- const hook = name.split( '.' ).reduce( ( node, key ) => node?.[ key ], manifest.hooks );
5653
+ //
5654
+ // OWN PROPERTIES ONLY. A bare property walk resolves 'resources.constructor'
5655
+ // to Object.prototype.constructor — a truthy FUNCTION, so every guard on
5656
+ // "does the vendor implement this" passes and runHook calls Object() with
5657
+ // the props, answering the caller's own payload back as a result. With the
5658
+ // api's hook route caching answers in shared Redis, that payload holds a
5659
+ // live admin token. Same fix providerFields already carries.
5660
+ const hook = name.split( '.' ).reduce(
5661
+ ( node, key ) => ( node && typeof node === 'object' && Object.hasOwn( node, key ) ) ? node[ key ] : undefined,
5662
+ manifest.hooks
5663
+ );
3472
5664
 
3473
5665
  if( hook === false || hook == null ){
3474
5666
 
@@ -3492,7 +5684,16 @@ const runHook = async ( slug, name, args = {} ) => {
3492
5684
  // disconnect until auth.oauth.urls gathered every vendor address in one
3493
5685
  // place, and a hook that cannot see its own manifest would have forced it
3494
5686
  // back. Callers never have to know to pass it.
3495
- return { outcome : OUTCOMES.answered, result : await hook({ ...args, manifest }) };
5687
+ const result = await hook({ ...props, manifest }, options );
5688
+
5689
+ // THE ANSWER IS CHECKED WHERE IT IS PRODUCED. A hook may describe writes,
5690
+ // enqueues and events for its caller to perform, and a malformed descriptor
5691
+ // is a write against the wrong collection — so it is refused here, at the one
5692
+ // call surface, rather than surviving as far as whoever performs it. A hook
5693
+ // that describes nothing (every resources.* read) validates to nothing.
5694
+ effectsOf( result );
5695
+
5696
+ return { outcome : OUTCOMES.answered, result };
3496
5697
 
3497
5698
  } catch ( error ) {
3498
5699
 
@@ -3672,7 +5873,11 @@ const resolveConnection = ( item, data, env = {} ) => {
3672
5873
 
3673
5874
  return Object.fromEntries(
3674
5875
  Object.entries( item )
3675
- .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
5876
+ // `provider` is in the list for a different reason than the rest: it is
5877
+ // PLATFORM configuration — which credentials an admin types in for this
5878
+ // vendor — and no merchant-facing resolution has any business carrying
5879
+ // it, even as bare descriptors.
5880
+ .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'steps', 'supports' ].includes( key ) )
3676
5881
  .map( ( [ key, value ] ) => [
3677
5882
  key,
3678
5883
  ( typeof value === 'function' ? value( data, env ) : value )
@@ -3681,4 +5886,4 @@ const resolveConnection = ( item, data, env = {} ) => {
3681
5886
 
3682
5887
  };
3683
5888
 
3684
- export { AUTH_TYPES, GROUPS, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };
5889
+ export { AUTH_TYPES, GROUPS, HOOKS, HOOK_EFFECTS, HOOK_NAMES, HOOK_OPTIONS, HOOK_PROPS, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, WRITE_OPERATIONS, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, effectsOf, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };