@drawbridge/drawbridge-utils 0.0.167 → 0.0.169

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,7 +1,7 @@
1
1
  import { authToken } from './oauth.cjs';
2
2
  export { consentUrl, pkcePair } from './oauth.cjs';
3
3
  import { toE164, detectCountry } from '../phone.cjs';
4
- import crypto, { createHmac, timingSafeEqual, createVerify, createHash, randomUUID } from 'node:crypto';
4
+ import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
5
5
  import { request } from '../http.cjs';
6
6
  import { channels } from '../pricing.cjs';
7
7
  import { customAlphabet } from 'nanoid';
@@ -186,7 +186,20 @@ const HOOKS = Object.freeze({
186
186
 
187
187
  sms : Object.freeze([ 'send' ]),
188
188
 
189
- segment : Object.freeze([ 'sync' ]),
189
+ segment : Object.freeze([
190
+ // Make this segment's object exist at the vendor, carrying the segment's
191
+ // current title, and describe the row that points at it. IDEMPOTENT: the
192
+ // same call creates it, renames it after an edit, and backfills a segment
193
+ // that predates the connection — so one hook serves every path and there
194
+ // is no create-vs-update branch to keep in step.
195
+ 'register',
196
+ // Remove the vendor object this connection's row points at. Called with
197
+ // the pre-image on a segment delete, because by then the document is gone.
198
+ 'remove',
199
+ // Recalculate Drawbridge-side membership. Private to the drawbridge
200
+ // manifest; a vendor does not own who is in a Drawbridge segment.
201
+ 'sync'
202
+ ]),
190
203
 
191
204
  // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
192
205
  // The Webhooks connection is the only thing here with no third party behind
@@ -516,6 +529,8 @@ const STEPS = Object.freeze({
516
529
  'email.digest' : 'Digest',
517
530
  'email.notify' : 'Notification',
518
531
  'email.send' : 'Send email',
532
+ 'segment.register' : 'Register segment',
533
+ 'segment.remove' : 'Remove segment',
519
534
  'segment.sync' : 'Sync segment',
520
535
  'sms.send' : 'Send SMS',
521
536
  'webhook.send' : 'Send webhook'
@@ -715,7 +730,16 @@ const tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
715
730
  ...( tokens.expiresIn && {
716
731
  expiresAt : new Date( now + ( tokens.expiresIn * 1000 ) ).toISOString()
717
732
  }),
718
- ...( tokens.scope && { scope : tokens.scope })
733
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
734
+ // authorization_code response and documents no response body at all for the
735
+ // refresh grant, so taking the minted value alone drops the stored one. That
736
+ // matters because `scope` is load-bearing: the segment hooks gate on
737
+ // `segments:write` and answer `skipped` when it is absent, so a connection
738
+ // that dropped it disables its whole segment half without failing anything
739
+ // and shows a reconnect task that reconnecting has already fixed.
740
+ ...( ( tokens.scope || existing.scope ) && {
741
+ scope : tokens.scope || existing.scope
742
+ })
719
743
  });
720
744
 
721
745
  // Returns a token that is good right now, refreshing and persisting first if the
@@ -792,6 +816,176 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
792
816
  <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"/>
793
817
  </svg>`;
794
818
 
819
+ // WHERE A DRAWBRIDGE SEGMENT WENT, one row per connection on the segment
820
+ // document. The row is not membership — membership stays as each vendor keeps
821
+ // it, as tags or properties or members — it says which vendor object stands for
822
+ // this segment, and where a merchant can open it.
823
+ //
824
+ // Shared rather than repeated in three manifests because the upsert is the
825
+ // subtle part: two descriptors, each a no-op when the other applies, so a
826
+ // register that changes nothing writes nothing and two concurrent registers
827
+ // converge on ONE ROW rather than two.
828
+ //
829
+ // THE ROW, NOT THE VENDOR OBJECT BEHIND IT. Each manifest searches by name
830
+ // before it creates, which NARROWS the window rather than closing it: two
831
+ // concurrent first registers can both search, both miss and both create, and a
832
+ // drift re-queue carries a deliberately unique job id precisely so it can run
833
+ // alongside a fresh dispatch. What the pair below guarantees is that only one of
834
+ // them is ever pointed at — the loser is an orphaned vendor object, which a
835
+ // merchant can see and delete, and not a row pointing at the wrong thing.
836
+
837
+ // NO `env` HERE, unlike Shopify's urls.manage( data, env ). That third argument
838
+ // is supplied by the api, which has one; a segment url is built inside a hook,
839
+ // and `env` is not in HOOK_PROPS — so a vendor reaching for it would receive
840
+ // undefined on every run. Threading a parameter nothing can fill is a trap for
841
+ // whoever writes the next manifest.
842
+ const row = ({ connection, data, manifest, row : described }) => ({
843
+ connection : connection?.id,
844
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
845
+ // strings, and one type in the schema is one comparison in the $or below.
846
+ id : String( described?.id ),
847
+ slug : connection?.slug,
848
+ type : described?.type,
849
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
850
+ // The url is built HERE, while the settings are decrypted and the vendor
851
+ // facts are in hand — an api reading the row later has neither.
852
+ url : manifest?.urls?.segment?.( { ...described, id : String( described?.id ) }, data ) || null
853
+ });
854
+
855
+ const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
856
+
857
+ const built = row({ connection, data, manifest, row : described });
858
+
859
+ return [
860
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
861
+ // add nothing rather than a duplicate row for one connection.
862
+ {
863
+ collection : 'segment',
864
+ data : { $push : { connections : built } },
865
+ operation : 'update',
866
+ query : {
867
+ id : segment?.id,
868
+ 'connections.connection' : { $ne : connection?.id }
869
+ }
870
+ },
871
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
872
+ // of its three mutable fields disagrees, so the steady state — the same
873
+ // vendor object, the same url — matches nothing and writes nothing.
874
+ {
875
+ collection : 'segment',
876
+ data : { $set : { 'connections.$' : built } },
877
+ operation : 'update',
878
+ query : {
879
+ id : segment?.id,
880
+ connections : {
881
+ $elemMatch : {
882
+ connection : connection?.id,
883
+ $or : [
884
+ { id : { $ne : built.id } },
885
+ { type : { $ne : built.type } },
886
+ { url : { $ne : built.url } }
887
+ ]
888
+ }
889
+ }
890
+ }
891
+ }
892
+ ];
893
+
894
+ };
895
+
896
+ const segmentRowRemoveWrites = ({ connection, segment }) => [
897
+ {
898
+ collection : 'segment',
899
+ data : { $pull : { connections : { connection : connection?.id } } },
900
+ operation : 'update',
901
+ query : { id : segment?.id }
902
+ }
903
+ ];
904
+
905
+ // THE ROW THIS CONNECTION ALREADY HAS, or undefined. The segments the shell
906
+ // hands a hook carry `connections`, so a register can answer "nothing to do"
907
+ // without a read.
908
+ const segmentRowFor = ({ connection, segment }) => ( segment?.connections || [] )
909
+ .find( ( entry ) => entry?.connection === connection?.id );
910
+
911
+ // THE SEGMENT AS IT IS NOW, not as it was when this run was dispatched.
912
+ //
913
+ // Four places dispatch register under ONE job id, which is what stops four
914
+ // dispatches becoming four vendor round-trips — but it also means the run
915
+ // carries whichever trigger data won the race. Re-reading is how a coalesced
916
+ // run applies the current title rather than a stale one.
917
+ //
918
+ // Falls back to the passed document when no reader was injected. `read` is an
919
+ // optional capability rather than a guaranteed one — a caller invoking runHook
920
+ // without a database reader still gets a working hook, it just applies the
921
+ // document it was handed.
922
+ const currentSegment = async ({ read, segment }) => {
923
+
924
+ if( ! read?.get || ! segment?.id ) return segment;
925
+
926
+ return read.get({
927
+ collection : 'segment',
928
+ query : { id : segment.id }
929
+ });
930
+
931
+ };
932
+
933
+ // ONE MORE PASS WHEN THE TITLE MOVED UNDER US.
934
+ //
935
+ // A rename that arrives while a register is ACTIVE is dropped, because the
936
+ // coalescing job id still exists — so the vendor object would keep the old name
937
+ // forever. Re-reading at the end catches it, and the re-queue carries a UNIQUE
938
+ // id, since the coalescing one is what dropped the rename in the first place.
939
+ //
940
+ // THE TIMESTAMP AND THE RANDOM SUFFIX ARE BOTH LOAD-BEARING. The timestamp is
941
+ // for a person reading the queue, who needs to see when a drift pass was
942
+ // raised; the random half is what actually makes the id unique, because two
943
+ // drift re-queues for one segment inside the same millisecond would otherwise
944
+ // collide back into a single job — dropping the very rename this exists to
945
+ // recover.
946
+ //
947
+ // ponytail: NO `priority`, unlike every dispatch that goes through
948
+ // drawbridge-sync's dispatchExecute — which computes one per organization so a
949
+ // tenant spiking past its window is deprioritized behind everyone else. A drift
950
+ // re-queue therefore enters at BullMQ's default and jumps that queue. Not
951
+ // fixable by taking a `priority` argument either: the number comes from an
952
+ // in-process counter in sync that this package cannot see and that a manifest
953
+ // hook is never handed. Closing it means sync stamping a priority onto described
954
+ // enqueues in step-runner's perform(), which is a change to every enqueue every
955
+ // hook describes — worth doing when a second caller wants it, not for one
956
+ // re-queue that fires only on a rename that landed mid-register.
957
+ const driftEnqueues = async ({ applied, read, segment, workflow }) => {
958
+
959
+ // NOTHING TO RE-QUEUE WITHOUT A WORKFLOW. The enqueue names the workflow to
960
+ // execute and keys its job id on the same id, so an absent one queues a job
961
+ // carrying workflowId : undefined under an id holding the literal string
962
+ // "undefined" — a job that cannot run and that collides with every other
963
+ // workflowless drift pass.
964
+ if( ! workflow?.id ) return [];
965
+
966
+ const fresh = await currentSegment({ read, segment });
967
+
968
+ if( ! fresh?.id || fresh.title === applied ) return [];
969
+
970
+ return [ {
971
+ data : {
972
+ triggerData : {
973
+ organization : fresh.organization || workflow?.organization,
974
+ segment : fresh
975
+ },
976
+ workflowId : workflow?.id
977
+ },
978
+ name : 'execute',
979
+ options : {
980
+ jobId : 'workflow.insert.execute.' + workflow?.id + '.segment.register.' + fresh.id + '.drift.' + Date.now() + '.' + randomUUID().slice( 0, 8 ),
981
+ removeOnComplete : true,
982
+ removeOnFail : true
983
+ },
984
+ queue : 'workflow'
985
+ } ];
986
+
987
+ };
988
+
795
989
  // ONE REQUEST SHAPE for every Attentive call, the way Klaviyo's file has one.
796
990
  // The path carries its own version — the segments picker is v2 and the
797
991
  // subscription writes are v1 — because Attentive versions per resource rather
@@ -908,7 +1102,7 @@ var attentive = {
908
1102
  // has to say so rather than let them believe otherwise.
909
1103
  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.',
910
1104
  description : [
911
- '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.',
1105
+ 'This connection syncs the contacts your campaigns collect into your Attentive account — subscribed for marketing, and added to the segment you choose.',
912
1106
  'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
913
1107
  '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.',
914
1108
  '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.'
@@ -950,10 +1144,9 @@ var attentive = {
950
1144
  }
951
1145
  ],
952
1146
  group : 'contacts',
953
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
954
- // nothing else is built yet, because subscriber sync has not shipped. Every
955
- // false here is "not yet" rather than "never" — when the sync lands, probe
956
- // and contacts.sync are the first to flip.
1147
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
1148
+ // a paragraph up here that goes stale the moment one of them is implemented
1149
+ // which is exactly what happened to the note this replaces.
957
1150
  hooks : {
958
1151
 
959
1152
  auth : {
@@ -967,7 +1160,7 @@ var attentive = {
967
1160
  // nobody re-derives it. Klaviyo's connect reads the account name back so
968
1161
  // the card is not blank; Attentive's card stays blank. There IS an
969
1162
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
970
- // on docs.attentive.com/pages/authentication/ as returning "information
1163
+ // on docs.attentive.com/docs/authentication as returning "information
971
1164
  // specific to your company" — but its RESPONSE SCHEMA is published
972
1165
  // nowhere we can read: the docs show the curl and no body. Reading
973
1166
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -1187,7 +1380,71 @@ var attentive = {
1187
1380
  promotions : false
1188
1381
 
1189
1382
  },
1190
- segment : false,
1383
+ segment : {
1384
+
1385
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
1386
+ // one with an externalId we choose (docs.attentive.com/reference/
1387
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
1388
+ // required, `externalId` optional and "auto-generated if not supplied"),
1389
+ // which would give a real per-segment object — but it takes
1390
+ // segments:write, and scopes ride on the app registration, which does not
1391
+ // exist yet.
1392
+ //
1393
+ // So the row points at the connection-level segment the merchant chose,
1394
+ // `type` says so, and turning this into a per-segment object later is a
1395
+ // change to this file and nothing else: create with
1396
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
1397
+ // update and archive endpoints are BOTH keyed by external id
1398
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
1399
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
1400
+ // already hold addresses every one of the three calls.
1401
+ //
1402
+ // NO DRIFT CHECK, unlike the other two: the row points at the
1403
+ // connection's own segment and the link is the index page, so nothing
1404
+ // here depends on the Drawbridge segment's title — a rename has nothing
1405
+ // to apply and nothing to race with. That comes back with the
1406
+ // per-segment object.
1407
+ //
1408
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
1409
+ // the simplest of the three registers and therefore the one the next
1410
+ // vendor gets copied from — one job id serves four dispatch sites, so a
1411
+ // copy that trusts context.segment applies whichever trigger data won
1412
+ // the race, at a vendor where the title does matter.
1413
+ register : async ( { connection, context, manifest, settings }, { read } = {} ) => {
1414
+
1415
+ const segment = await currentSegment({ read, segment : context?.segment });
1416
+
1417
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
1418
+
1419
+ if( ! settings?.segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
1420
+
1421
+ return {
1422
+ events : [ {
1423
+ event : 'organization.segments',
1424
+ payload : { id : segment.id },
1425
+ room : 'organization.' + connection?.organization
1426
+ } ],
1427
+ message : 'Contacts in this segment are added to the Attentive segment chosen on this connection.',
1428
+ writes : segmentRowWrites({
1429
+ connection,
1430
+ data : { ...connection, settings },
1431
+ manifest,
1432
+ row : { id : settings.segment, type : 'segment' },
1433
+ segment
1434
+ })
1435
+ };
1436
+
1437
+ },
1438
+
1439
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
1440
+ // and it is where every Drawbridge segment's contacts go — removing it
1441
+ // because one Drawbridge segment was deleted would empty the others.
1442
+ remove : false,
1443
+
1444
+ // Drawbridge-side membership belongs to the private manifest.
1445
+ sync : false
1446
+
1447
+ },
1191
1448
  sms : false,
1192
1449
  webhook : false
1193
1450
 
@@ -1217,6 +1474,21 @@ var attentive = {
1217
1474
  'ATTENTIVE_OAUTH_CLIENT_ID',
1218
1475
  'ATTENTIVE_OAUTH_CLIENT_SECRET'
1219
1476
  ],
1477
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
1478
+ review : {
1479
+ api : 'https://docs.attentive.com/reference/listsegments',
1480
+ dashboard : 'https://docs.attentive.com/docs/segments',
1481
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
1482
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
1483
+ // privacy_requests:write — and says nothing about segments:read or
1484
+ // segments:write, which the segments API this manifest calls does take.
1485
+ // The header at the top of this file carries that distinction; it is
1486
+ // repeated here so a reviewer following the link is not misled by what the
1487
+ // table omits (fetched 2026-09-11).
1488
+ scopes : 'https://docs.attentive.com/docs/authentication',
1489
+ content : '2026-09-11',
1490
+ verified : null
1491
+ },
1220
1492
  slug : 'attentive',
1221
1493
  // A consent with no segment chosen is authenticated and inert — the sync needs
1222
1494
  // somewhere to put people — so the card says Pending rather than Active over
@@ -1263,6 +1535,27 @@ var attentive = {
1263
1535
 
1264
1536
  })
1265
1537
 
1538
+ },
1539
+
1540
+ segment : {
1541
+
1542
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
1543
+ // this fires from the segment's own lifecycle, not from a workflow
1544
+ // somebody assembled. The trigger is declared here rather than hard-coded
1545
+ // in drawbridge-sync.
1546
+ //
1547
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
1548
+ // declined, and build() refuses a step pointing at a hook this vendor does
1549
+ // not implement — so the two are one decision, enforced at import.
1550
+ register : () => ({
1551
+ description : 'Records which Attentive segment a Drawbridge segment\'s contacts are added to.',
1552
+ hook : 'segment.register',
1553
+ key : 'Attentive Segment Register',
1554
+ queue : 'connection',
1555
+ system : true,
1556
+ trigger : { event : 'segment.register', type : 'event' }
1557
+ })
1558
+
1266
1559
  }
1267
1560
 
1268
1561
  },
@@ -1280,7 +1573,22 @@ var attentive = {
1280
1573
  }
1281
1574
  ]
1282
1575
  ),
1283
- title : 'Attentive'
1576
+ title : 'Attentive',
1577
+
1578
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1579
+ // only identifier we hold is the API's externalId, which their UI may not
1580
+ // path by — so this lands on the list, where the merchant finds it by name.
1581
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1582
+ //
1583
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1584
+ // segment url carries. What is on record is that the segments area lives at
1585
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1586
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1587
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1588
+ // walk-through confirms this against a real account before promote.
1589
+ urls : {
1590
+ segment : () => 'https://ui.attentivemobile.com/segments/all'
1591
+ }
1284
1592
  };
1285
1593
 
1286
1594
  // THE SHARED HALF OF inbound.verify.
@@ -2145,7 +2453,7 @@ var drawbridge = {
2145
2453
  content : {
2146
2454
  confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
2147
2455
  description : [
2148
- 'Drawbridge sends your notification email and SMS, keeps your segments in sync, and posts to your own endpoints. These are built in rather than connected, so there is nothing here to set up.'
2456
+ 'Drawbridge sends your notification emails and your entrants\' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected.'
2149
2457
  ],
2150
2458
  excerpt : 'The steps Drawbridge runs itself.',
2151
2459
  guide : [
@@ -2397,25 +2705,10 @@ var drawbridge = {
2397
2705
 
2398
2706
  if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
2399
2707
 
2400
- // A LIVE SUBSCRIPTION, in two reads. Free organizations get system mail
2401
- // only; a lead-facing send is a paid feature.
2402
- const organization = await read.get({ collection : 'organization', query : { id : workflow.organization } });
2403
-
2404
- const subscription = organization?.subscription
2405
- ? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
2406
- : null;
2407
-
2408
- if( subscription?.status !== 'active' ){
2409
-
2410
- return {
2411
- message : 'Organization has no active subscription — workflow-step email skipped.',
2412
- request,
2413
- response : { skipped : true },
2414
- skipped : true
2415
- };
2416
-
2417
- }
2418
-
2708
+ // NO SUBSCRIPTION CHECK. The send is billed as an action, so the plan's
2709
+ // allowance is the entitlement and a free organization sends within its
2710
+ // 200 like any other. Entries stop at the free cap before a workflow
2711
+ // can fire on them, which is where the cap is enforced.
2419
2712
  return {
2420
2713
  message : 'Email queued for delivery to ' + to + '.',
2421
2714
  request,
@@ -2606,6 +2899,12 @@ var drawbridge = {
2606
2899
  },
2607
2900
  segment : {
2608
2901
 
2902
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2903
+ // vendor, and this manifest has no vendor behind it — the three that do
2904
+ // implement these.
2905
+ register : false,
2906
+ remove : false,
2907
+
2609
2908
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2610
2909
  // contact in an organization against every segment, which is too much for
2611
2910
  // one job, so it returns chunks and the shell defers completion.
@@ -2971,6 +3270,33 @@ var drawbridge = {
2971
3270
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2972
3271
  // empty the moment this was added.
2973
3272
  requires : [],
3273
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
3274
+ // manifest, so `false` would be a lie about which reads were made.
3275
+ //
3276
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
3277
+ // citation here would evidence one of them and read as though it covered all
3278
+ // three, which is the omission this key exists to catch.
3279
+ review : {
3280
+ api : {
3281
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
3282
+ hubspot : 'https://developers.hubspot.com/docs/reference/api/crm/objects/contacts',
3283
+ // lib/sendgrid.js posts to /v3/mail/send.
3284
+ sendgrid : 'https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send',
3285
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
3286
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
3287
+ twilio : 'https://www.twilio.com/docs/messaging/api/message-resource'
3288
+ },
3289
+ dashboard : {
3290
+ hubspot : 'https://knowledge.hubspot.com/contacts/create-contacts',
3291
+ sendgrid : 'https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed',
3292
+ twilio : 'https://www.twilio.com/docs/messaging/guides/debugging-tools'
3293
+ },
3294
+ // An admin types these keys in; there is no merchant consent and no scope
3295
+ // model on any of the three.
3296
+ scopes : false,
3297
+ content : '2026-09-11',
3298
+ verified : null
3299
+ },
2974
3300
  slug : 'drawbridge',
2975
3301
  // Always on. There is no credential that could go bad and no configuration a
2976
3302
  // merchant could leave half-finished.
@@ -3186,6 +3512,25 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3186
3512
 
3187
3513
  };
3188
3514
 
3515
+ // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3516
+ // below keys on the id — so a rename never has to touch a profile.
3517
+ const segmentName = ( title ) => 'Drawbridge: ' + title;
3518
+
3519
+ // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3520
+ // only what the merchant consented to, so a connection made before segments were
3521
+ // asked for holds one that cannot write a segment — Create, Update and Delete
3522
+ // Segment each list `segments:write`
3523
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3524
+ // 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
3525
+ // nothing; this does.
3526
+ //
3527
+ // `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
3528
+ // describes as "The scopes that this access token has access to for accessing
3529
+ // API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
3530
+ // 2026-09-11) — not the list this manifest asks for, which is why a connection
3531
+ // older than the ask reads as false rather than true.
3532
+ const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
3533
+
3189
3534
  // Klaviyo — contact sync, over OAuth.
3190
3535
  //
3191
3536
  // EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
@@ -3235,9 +3580,21 @@ var klaviyo = {
3235
3580
  // exchange, and a copy here would be a second answer that goes stale.
3236
3581
  expiry : 90 * 24 * 60 * 60,
3237
3582
  pkce : true,
3583
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
3584
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
3585
+ // and set them using a space-separated list"
3586
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
3587
+ // 2026-09-11) — and a merchant's token only ever carries what they
3588
+ // consented to, so a scope added later is a reconnect for every one of
3589
+ // them. That is what segments cost when they were left out here.
3590
+ //
3238
3591
  // Space separated. accounts:read is required by Klaviyo on every app
3239
- // and must stay in the list; the rest are what a contact sync needs.
3240
- scopes : 'accounts:read lists:read lists:write profiles:read profiles:write',
3592
+ // and must stay in the list; the rest are what a contact sync and the
3593
+ // segment hooks need — Get Segments lists `segments:read`, Create,
3594
+ // Update and Delete Segment each list `segments:write`
3595
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3596
+ // revision 2026-07-15, fetched 2026-09-11).
3597
+ scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
3241
3598
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3242
3599
  // the disconnect hook — three vendor addresses, two of them declared,
3243
3600
  // which is exactly the kind of split that goes unnoticed.
@@ -3288,9 +3645,10 @@ var klaviyo = {
3288
3645
  confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
3289
3646
 
3290
3647
  description : [
3291
- 'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.',
3648
+ 'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.',
3292
3649
  'You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.',
3293
- '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.'
3650
+ '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.',
3651
+ 'Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them.'
3294
3652
  ],
3295
3653
 
3296
3654
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
@@ -3551,7 +3909,14 @@ var klaviyo = {
3551
3909
  // `segments` is null when the run carried no contact document,
3552
3910
  // meaning nobody looked — different from [], which means they
3553
3911
  // are in none. Null omits the key and merge leaves it alone.
3554
- ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3912
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) }),
3913
+ // THE IDS, which is what a Drawbridge-made segment's definition
3914
+ // filters on. Ids rather than titles, so renaming a segment is a
3915
+ // name change at Klaviyo and not a resync of every profile.
3916
+ //
3917
+ // The titles stay beside them: merchants have been building
3918
+ // their own segments on that array since it shipped.
3919
+ ...( segments && { drawbridge_segment_ids : segments.map( ( entry ) => entry.id ).filter( Boolean ) })
3555
3920
  }
3556
3921
  },
3557
3922
  type : 'profile'
@@ -3614,17 +3979,218 @@ var klaviyo = {
3614
3979
 
3615
3980
  },
3616
3981
 
3617
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3618
- // register nothing with it, so listing four falses would be noise around a
3619
- // single decision. Still explicit absence would not say whether anybody
3620
- // considered it.
3621
- // Drawbridge sends its own notification email and SMS, and owns its own
3622
- // segments — see the private `drawbridge` manifest. A vendor answering
3623
- // these would be a second sender, which is the arrangement the platform
3624
- // sender replaced.
3982
+ // Drawbridge sends its own notification email. A vendor answering this
3983
+ // would be a second sender, which is the arrangement the platform sender
3984
+ // replaced. Declined as one line rather than one per verb, because the whole
3985
+ // domain is one decision — still explicit, since absence would not say
3986
+ // whether anybody considered it.
3625
3987
  email : false,
3626
- segment : false,
3988
+
3989
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3990
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3991
+ // below — while register and remove keep a Klaviyo segment standing for
3992
+ // each Drawbridge segment, so the merchant can target one in their own
3993
+ // flows.
3994
+ segment : {
3995
+
3996
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3997
+ //
3998
+ // Klaviyo owns no writable membership — its segments are computed from
3999
+ // rules — so the segment we create is DEFINED BY the profile property
4000
+ // contacts.sync writes. The definition filters on the Drawbridge
4001
+ // segment's ID, never its title, which is what makes a rename one PATCH
4002
+ // instead of a resync of every profile in it.
4003
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4004
+
4005
+ // AS IT IS NOW — see the same note on Mailchimp's. One job id serves
4006
+ // four dispatch sites, so the trigger data is whichever one won.
4007
+ const segment = await currentSegment({ read, segment : context?.segment });
4008
+
4009
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
4010
+
4011
+ if( ! canManageSegments( settings ) ){
4012
+
4013
+ return {
4014
+ message : 'Reconnect Klaviyo to let Drawbridge manage segments — this connection was made before that permission was asked for.',
4015
+ skipped : true
4016
+ };
4017
+
4018
+ }
4019
+
4020
+ const name = segmentName( segment.title );
4021
+ const existing = segmentRowFor({ connection, segment });
4022
+
4023
+ let id = null;
4024
+
4025
+ if( existing?.id ){
4026
+
4027
+ try {
4028
+
4029
+ const found = await api$1( '/segments/' + existing.id, { fetcher, token });
4030
+
4031
+ id = found?.data?.id ?? existing.id;
4032
+
4033
+ if( found?.data?.attributes?.name !== name ){
4034
+
4035
+ // NAME ONLY. Update Segment takes `name` on its own —
4036
+ // nothing in its attributes is required — and the definition
4037
+ // keys on the segment id, which has not changed, so rewriting
4038
+ // it would rebuild the segment for nothing.
4039
+ await api$1( '/segments/' + existing.id, {
4040
+ fetcher,
4041
+ method : 'PATCH',
4042
+ payload : { data : { attributes : { name }, id : existing.id, type : 'segment' } },
4043
+ token
4044
+ });
4045
+
4046
+ }
4047
+
4048
+ } catch ( error ){
4049
+
4050
+ // A 404 means the merchant deleted the segment themselves — fall
4051
+ // through and rebuild rather than failing a step they caused.
4052
+ if( error.status !== 404 ) throw error;
4053
+
4054
+ id = null;
4055
+
4056
+ }
4057
+
4058
+ }
4059
+
4060
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
4061
+ // closing it. Klaviyo segment names are not unique, so two
4062
+ // overlapping registers — a contact joining seconds after the segment
4063
+ // was made, or a drift re-queue whose deliberately unique job id lets
4064
+ // it run alongside a fresh dispatch — would otherwise leave a second
4065
+ // segment nothing points at. Two concurrent FIRST registers can still
4066
+ // both search, both miss and both create; only one row survives, so
4067
+ // the loser is an orphaned segment rather than a wrong one.
4068
+ //
4069
+ // `equals` is one of the two operators Get Segments allows on `name`,
4070
+ // and a string argument is quoted: "we will accept either single
4071
+ // quoted or double-quoted strings"
4072
+ // (developers.klaviyo.com/en/docs/filtering_, fetched 2026-09-11).
4073
+ // The whole expression is URI-encoded, which the same page requires.
4074
+ //
4075
+ // THE TITLE IS ESCAPED, because it is free text a merchant types and a
4076
+ // double quote in it would otherwise close the literal early — a
4077
+ // malformed filter is a 400 the step retries three times before
4078
+ // failing with no row written. The same page gives the escape: "Single
4079
+ // or double-quoted characters within strings (quoted with like quote
4080
+ // characters) MUST be escaped with a single backslash (i.e. 'Tony\'s
4081
+ // ball')". Only the matching quote needs it, so a double-quoted
4082
+ // literal escapes double quotes and leaves apostrophes alone.
4083
+ if( ! id ){
4084
+
4085
+ const search = await api$1( '/segments?filter=' + encodeURIComponent( 'equals(name,"' + name.replace( /"/g, '\\"' ) + '")' ), { fetcher, token });
4086
+
4087
+ id = ( search?.data || [] ).find( ( entry ) => entry?.attributes?.name === name )?.id ?? null;
4088
+
4089
+ }
4090
+
4091
+ if( ! id ){
4092
+
4093
+ const created = await api$1( '/segments', {
4094
+ fetcher,
4095
+ method : 'POST',
4096
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
4097
+ // — `name` and `definition` are both required on its attributes
4098
+ // — and a custom profile property is addressed as
4099
+ // "properties['property name']", tested with a list filter whose
4100
+ // operator is `contains`
4101
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
4102
+ // revision 2026-07-15, fetched 2026-09-11).
4103
+ payload : {
4104
+ data : {
4105
+ attributes : {
4106
+ definition : {
4107
+ condition_groups : [ {
4108
+ conditions : [ {
4109
+ filter : { operator : 'contains', type : 'list', value : segment.id },
4110
+ property : 'properties[\'drawbridge_segment_ids\']',
4111
+ type : 'profile-property'
4112
+ } ]
4113
+ } ]
4114
+ },
4115
+ name
4116
+ },
4117
+ type : 'segment'
4118
+ }
4119
+ },
4120
+ token
4121
+ });
4122
+
4123
+ id = created?.data?.id;
4124
+
4125
+ }
4126
+
4127
+ if( ! id ) return { message : 'Klaviyo returned no segment id.', skipped : true };
4128
+
4129
+ return {
4130
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4131
+ // coalescing job id, so the last thing this does is look again.
4132
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
4133
+ events : [ {
4134
+ event : 'organization.segments',
4135
+ payload : { id : segment.id },
4136
+ room : 'organization.' + connection?.organization
4137
+ } ],
4138
+ message : 'Klaviyo is carrying this segment as "' + name + '".',
4139
+ writes : segmentRowWrites({
4140
+ connection,
4141
+ data : { ...connection, settings },
4142
+ manifest,
4143
+ row : { id, type : 'segment' },
4144
+ segment
4145
+ })
4146
+ };
4147
+
4148
+ },
4149
+
4150
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
4151
+ // copy, and it carries the row naming what to delete.
4152
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
4153
+
4154
+ const segment = context?.segment;
4155
+ const existing = segmentRowFor({ connection, segment });
4156
+
4157
+ if( ! existing?.id ) return { message : 'Klaviyo was never carrying this segment.', skipped : true };
4158
+
4159
+ if( ! canManageSegments( settings ) ){
4160
+
4161
+ return { message : 'Reconnect Klaviyo to let Drawbridge manage segments.', skipped : true };
4162
+
4163
+ }
4164
+
4165
+ try {
4166
+
4167
+ await api$1( '/segments/' + existing.id, { fetcher, method : 'DELETE', token });
4168
+
4169
+ } catch ( error ){
4170
+
4171
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
4172
+ // retrying a 404 twice more achieves nothing.
4173
+ if( error.status !== 404 ) throw error;
4174
+
4175
+ }
4176
+
4177
+ return {
4178
+ message : 'Klaviyo is no longer carrying this segment.',
4179
+ writes : segmentRowRemoveWrites({ connection, segment })
4180
+ };
4181
+
4182
+ },
4183
+
4184
+ // Drawbridge-side membership belongs to the private manifest.
4185
+ sync : false
4186
+
4187
+ },
4188
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4189
+ // notification SMS, and a vendor answering this would be a second sender.
3627
4190
  sms : false,
4191
+
4192
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
4193
+ // to verify.
3628
4194
  inbound : false,
3629
4195
 
3630
4196
  // Nothing to set up or tear down at the vendor: the grant is the whole
@@ -3800,6 +4366,18 @@ var klaviyo = {
3800
4366
  'KLAVIYO_OAUTH_CLIENT_ID',
3801
4367
  'KLAVIYO_OAUTH_CLIENT_SECRET'
3802
4368
  ],
4369
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4370
+ review : {
4371
+ api : 'https://developers.klaviyo.com/en/reference/api_overview',
4372
+ dashboard : 'https://help.klaviyo.com/hc/en-us/articles/115005078647',
4373
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
4374
+ // example scope string and nothing to check a manifest against; this page
4375
+ // lists the scopes each API takes, segments:read and segments:write among
4376
+ // them (fetched 2026-09-11).
4377
+ scopes : 'https://developers.klaviyo.com/en/docs/authenticate_',
4378
+ content : '2026-09-11',
4379
+ verified : null
4380
+ },
3803
4381
  slug : 'klaviyo',
3804
4382
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3805
4383
  // is already the merchant-facing copy channel and is already rendered.
@@ -3830,7 +4408,8 @@ var klaviyo = {
3830
4408
  hook : 'lifecycle.health',
3831
4409
  key : 'Klaviyo Connection Health',
3832
4410
  queue : 'connection',
3833
- system : true
4411
+ system : true,
4412
+ trigger : { event : 'day', type : 'schedule' }
3834
4413
  })
3835
4414
  }
3836
4415
 
@@ -3883,6 +4462,32 @@ var klaviyo = {
3883
4462
 
3884
4463
  })
3885
4464
 
4465
+ },
4466
+
4467
+ segment : {
4468
+
4469
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4470
+ // these fire from the segment's own lifecycle, not from a workflow
4471
+ // somebody assembled. The trigger is declared here rather than hard-coded
4472
+ // in drawbridge-sync.
4473
+ register : () => ({
4474
+ description : 'Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.',
4475
+ hook : 'segment.register',
4476
+ key : 'Klaviyo Segment Register',
4477
+ queue : 'connection',
4478
+ system : true,
4479
+ trigger : { event : 'segment.register', type : 'event' }
4480
+ }),
4481
+
4482
+ remove : () => ({
4483
+ description : 'Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.',
4484
+ hook : 'segment.remove',
4485
+ key : 'Klaviyo Segment Remove',
4486
+ queue : 'connection',
4487
+ system : true,
4488
+ trigger : { event : 'segment.remove', type : 'event' }
4489
+ })
4490
+
3886
4491
  }
3887
4492
 
3888
4493
  },
@@ -3891,17 +4496,43 @@ var klaviyo = {
3891
4496
  // is reconnecting — prompting "choose a list" there asks the merchant to
3892
4497
  // configure a grant that no longer exists. `pending` is exactly this task's
3893
4498
  // moment: the grant is good and the list is the missing half.
3894
- tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.list
3895
- ? []
3896
- : [
3897
- {
4499
+ tasks : ( data ) => {
4500
+
4501
+ if( ! [ 'active', 'pending' ].includes( data?.status ) ) return [];
4502
+
4503
+ // BOTH, WHEN BOTH APPLY. These are independent facts about one connection
4504
+ // — an old grant that cannot manage segments, and a list nobody picked —
4505
+ // and returning only the first means a merchant fixes it, comes back, and
4506
+ // discovers the second. The grant leads because reconnecting is the longer
4507
+ // errand.
4508
+ return [
4509
+ // A connection made before segments were requested is authenticated and
4510
+ // cannot manage them, and no error surfaces anywhere else — the register
4511
+ // runs skip rather than fail.
4512
+ ...( canManageSegments( data?.settings ) ? [] : [ {
4513
+ message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4514
+ title : 'Reconnect Klaviyo'
4515
+ } ] ),
4516
+ ...( data?.settings?.list ? [] : [ {
3898
4517
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
3899
4518
  title : 'Choose a list'
3900
- }
3901
- ]
3902
- ),
4519
+ } ] )
4520
+ ];
4521
+
4522
+ },
3903
4523
 
3904
- title : 'Klaviyo'
4524
+ title : 'Klaviyo',
4525
+
4526
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
4527
+ // is its own help centre on a list: "you can find a list's ID in the URL in
4528
+ // your browser when viewing this list"
4529
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
4530
+ // segment's page is the sibling form of it. The path itself is NOT published
4531
+ // anywhere citable, so the dev walk-through confirms this against a real
4532
+ // account before promote.
4533
+ urls : {
4534
+ segment : ( row ) => ( row?.id ? 'https://www.klaviyo.com/segment/' + row.id : null )
4535
+ }
3905
4536
  };
3906
4537
 
3907
4538
  // Mailchimp, exported from the brand kit and left as authored — the fills are the
@@ -3982,6 +4613,11 @@ const subscriberHash = ( email ) => createHash( 'md5' )
3982
4613
  .update( String( email ).trim().toLowerCase() )
3983
4614
  .digest( 'hex' );
3984
4615
 
4616
+ // THE TAG'S NAME, and the one place it is spelled. The member write and the
4617
+ // register hook must agree character for character — they address the same
4618
+ // object, one by name and one by id — so a prefix change is one edit here.
4619
+ const tagName = ( title ) => 'Drawbridge: ' + title;
4620
+
3985
4621
  // Mailchimp — contact sync, not a sender.
3986
4622
  var mailchimp = {
3987
4623
  // OAUTH 2, authorization code. Every url below is quoted from
@@ -4025,7 +4661,7 @@ var mailchimp = {
4025
4661
  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.',
4026
4662
  description : [
4027
4663
  'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.',
4028
- '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.',
4664
+ 'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so you can market to the people who enter a giveaway alongside the rest of your list.',
4029
4665
  '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.',
4030
4666
  'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
4031
4667
  ],
@@ -4038,6 +4674,7 @@ var mailchimp = {
4038
4674
  'Sign in to Mailchimp if you are not already, and choose the account to connect.',
4039
4675
  'You come back here to pick the audience your contacts should sync into.',
4040
4676
  'The connection shows Pending until you pick an audience, then Active.',
4677
+ 'Each of your Drawbridge segments appears in that audience as a tag named "Drawbridge: " plus the segment name. Renaming a segment in Drawbridge renames its tag, and deleting the segment deletes the tag.',
4041
4678
  'You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account.'
4042
4679
  ]
4043
4680
  },
@@ -4063,10 +4700,9 @@ var mailchimp = {
4063
4700
  }
4064
4701
  ],
4065
4702
  group : 'contacts',
4066
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
4067
- // else is built yet, because audience sync has not shipped. Every false here
4068
- // is "not yet" rather than "never" when the sync lands, probe and
4069
- // contacts.sync are the first to flip.
4703
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
4704
+ // a paragraph up here that goes stale the moment one of them is implemented
4705
+ // which is exactly what happened to the note this replaces.
4070
4706
  hooks : {
4071
4707
 
4072
4708
  auth : {
@@ -4150,6 +4786,45 @@ var mailchimp = {
4150
4786
 
4151
4787
  const hash = subscriberHash( email );
4152
4788
 
4789
+ // NAME AND PHONE TRAVEL AS MERGE FIELDS, because there is no other
4790
+ // way to write them. Mailchimp's member object has a `full_name`,
4791
+ // but their own schema marks it `"readOnly" : true` — it is DERIVED
4792
+ // from FNAME and LNAME
4793
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Members/Response.json,
4794
+ // fetched 2026-09-11), so writing a whole name in one field is not
4795
+ // on offer at any price.
4796
+ //
4797
+ // ONLY THE DEFAULT TAGS. A merge tag the audience does not have is
4798
+ // refused along with the whole member request, and "merge fields
4799
+ // with the tags *|FNAME|*, *|LNAME|*, *|ADDRESS|* and *|PHONE|* are
4800
+ // present by default when an audience is created"
4801
+ // (mailchimp.com/developer/marketing/docs/merge-fields, fetched
4802
+ // 2026-09-11) — so these are the ones safe to send without
4803
+ // registering anything first.
4804
+ //
4805
+ // ADDRESS IS LEFT OUT DELIBERATELY: `lead.address` is an array of
4806
+ // free-text strings and Mailchimp's ADDRESS wants a structured
4807
+ // object (addr1/city/state/zip/country). There is no honest mapping,
4808
+ // and a malformed one takes the member request down with it.
4809
+ //
4810
+ // A lead carries ONE `name`, so the last name is everything after
4811
+ // the first whitespace run — "Ada Lovelace" splits Ada/Lovelace, and
4812
+ // a single-word name sends no LNAME rather than an empty one.
4813
+ const [ firstName, ...restOfName ] = String( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
4814
+
4815
+ const lastName = restOfName.join( ' ' );
4816
+
4817
+ // The raw number the entrant gave, NOT the canonical form: this is a
4818
+ // field the merchant will contact them on, and canonical values are
4819
+ // for identity matching only.
4820
+ const phone = lead?.phone?.number || null;
4821
+
4822
+ const mergeFields = {
4823
+ ...( firstName && { FNAME : firstName }),
4824
+ ...( lastName && { LNAME : lastName }),
4825
+ ...( phone && { PHONE : phone })
4826
+ };
4827
+
4153
4828
  // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
4154
4829
  //
4155
4830
  // Omitting them means Mailchimp never learns they said no, so the
@@ -4176,14 +4851,12 @@ var mailchimp = {
4176
4851
  method : 'PUT',
4177
4852
  payload : {
4178
4853
  email_address : email,
4179
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
4180
- // schemaless a merge tag that does not exist on the audience is
4181
- // refused, taking the whole request with it and FNAME is one of
4182
- // the two tags every audience is created with. The Drawbridge
4183
- // totals Klaviyo receives cannot travel until something registers
4184
- // merge fields on the chosen audience, which is lifecycle.register's
4185
- // job and is not built.
4186
- ...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
4854
+ // Built above. Omitted entirely when there is nothing to say, so a
4855
+ // lead with only an address does not send an empty object. The
4856
+ // Drawbridge totals Klaviyo receives still cannot travel this way
4857
+ // those are custom tags, and registering them on the chosen audience
4858
+ // is lifecycle.register's job and is not built.
4859
+ ...( Object.keys( mergeFields ).length > 0 && { merge_fields : mergeFields }),
4187
4860
  ...( suppressed && { status : 'unsubscribed' }),
4188
4861
  status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
4189
4862
  },
@@ -4199,8 +4872,8 @@ var mailchimp = {
4199
4872
  // and is not built. A tag needs no setup: "If a tag that does not exist
4200
4873
  // is passed in and set as 'active', a new tag will be created"
4201
4874
  // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
4202
- // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
4203
- // to explain in the guide.
4875
+ // fetched 2026-09-09). Nothing for the merchant to prepare the guide
4876
+ // says what appears in their audience, not what to set up first.
4204
4877
  //
4205
4878
  // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
4206
4879
  // over time. Drawbridge segments are dynamic, and nothing dispatches a
@@ -4241,7 +4914,7 @@ var mailchimp = {
4241
4914
  .map( ( entry ) => entry.title )
4242
4915
  .filter( Boolean )
4243
4916
  .map( ( title ) => ({
4244
- name : 'Drawbridge: ' + title,
4917
+ name : tagName( title ),
4245
4918
  status : joined.has( title ) ? 'active' : 'inactive'
4246
4919
  }) );
4247
4920
 
@@ -4273,12 +4946,211 @@ var mailchimp = {
4273
4946
  }
4274
4947
 
4275
4948
  },
4276
- // Drawbridge sends its own notification email and SMS, and owns its own
4277
- // segments see the private `drawbridge` manifest. A vendor answering
4278
- // these would be a second sender, which is the arrangement the platform
4279
- // sender replaced.
4949
+ // Drawbridge sends its own notification email. A vendor answering this
4950
+ // would be a second sender, which is the arrangement the platform sender
4951
+ // replaced.
4280
4952
  email : false,
4281
- segment : false,
4953
+
4954
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
4955
+ // membership — see the private `drawbridge` manifest, and `sync : false`
4956
+ // below — while register and remove keep a Mailchimp tag standing for each
4957
+ // Drawbridge segment, so the merchant can target one in their own audience.
4958
+ segment : {
4959
+
4960
+ // THE TAG THIS SEGMENT IS, held by id at last.
4961
+ //
4962
+ // Tags ARE static segments in Mailchimp's model — same collection, same
4963
+ // ids — so this creates one through /segments and the member write goes
4964
+ // on attaching people to it by name. Both address the same object. The
4965
+ // segment schema says it outright: "The type of segment. Static segments
4966
+ // are now known as tags"
4967
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
4968
+ //
4969
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4970
+ // sweep and on backfill, it converges. That is what lets one hook serve
4971
+ // all four without a create-vs-update branch anywhere else.
4972
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4973
+
4974
+ const audience = settings?.audience;
4975
+
4976
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
4977
+
4978
+ // AS IT IS NOW, not as it was dispatched. Four places queue register
4979
+ // under one job id, so this run carries whichever of them won — and
4980
+ // applying that title would undo a rename that arrived after it.
4981
+ const segment = await currentSegment({ read, segment : context?.segment });
4982
+
4983
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
4984
+
4985
+ const name = tagName( segment.title );
4986
+ const existing = segmentRowFor({ connection, segment });
4987
+
4988
+ let id = null;
4989
+
4990
+ // BY ID FIRST, because that is the only path that can rename rather
4991
+ // than orphan. A 404 means the merchant deleted the tag themselves —
4992
+ // fall through and rebuild rather than failing a step they caused.
4993
+ if( existing?.id ){
4994
+
4995
+ try {
4996
+
4997
+ const found = await api( '/lists/' + audience + '/segments/' + existing.id, { dc : settings?.dc, fetcher, token });
4998
+
4999
+ id = found?.id ?? existing.id;
5000
+
5001
+ if( found?.name !== name ){
5002
+
5003
+ await api( '/lists/' + audience + '/segments/' + existing.id, {
5004
+ dc : settings?.dc,
5005
+ fetcher,
5006
+ method : 'PATCH',
5007
+ payload : { name },
5008
+ token
5009
+ });
5010
+
5011
+ }
5012
+
5013
+ } catch ( error ){
5014
+
5015
+ if( error.status !== 404 ) throw error;
5016
+
5017
+ id = null;
5018
+
5019
+ }
5020
+
5021
+ }
5022
+
5023
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
5024
+ // closing it. Two registers can overlap — a contact joining seconds
5025
+ // after the segment was made does it, and a drift re-queue carries a
5026
+ // deliberately unique job id so it can run alongside a fresh dispatch
5027
+ // — and creating without looking leaves a second tag nothing points
5028
+ // at. Two concurrent FIRST registers can still both search, both miss
5029
+ // and both create; only one row survives, so the loser is an orphaned
5030
+ // tag rather than a wrong one. Mailchimp offers no unique-name
5031
+ // constraint to close it properly.
5032
+ //
5033
+ // tag-search matches on PREFIX, not on the exact name: "The search
5034
+ // query will be compared to each tag as a prefix, so all tags that
5035
+ // have a name starting with this field will be returned"
5036
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11). So
5037
+ // "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
5038
+ // the row to the first result would point this segment at a different
5039
+ // merchant's tag. The exact name is filtered here.
5040
+ //
5041
+ // NO `count`, because the endpoint takes none: its only parameters are
5042
+ // the list id and `name`
5043
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/TagSearch.json, fetched
5044
+ // 2026-09-11), unlike the audiences hook above where the default of ten
5045
+ // does bite. The response carries `total_items`, which is the one hint
5046
+ // that a page was cut — so if a merchant ever keeps more tags under one
5047
+ // Drawbridge prefix than a page holds, that field is where it shows.
5048
+ if( ! id ){
5049
+
5050
+ const search = await api( '/lists/' + audience + '/tag-search?name=' + encodeURIComponent( name ), { dc : settings?.dc, fetcher, token });
5051
+
5052
+ id = ( search?.tags || [] ).find( ( tag ) => tag?.name === name )?.id ?? null;
5053
+
5054
+ }
5055
+
5056
+ if( ! id ){
5057
+
5058
+ const created = await api( '/lists/' + audience + '/segments', {
5059
+ dc : settings?.dc,
5060
+ fetcher,
5061
+ method : 'POST',
5062
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
5063
+ // name; this call only has to make the object exist. Mailchimp's
5064
+ // own wording for the empty array: "Passing an empty array will
5065
+ // create a static segment without any subscribers."
5066
+ payload : { name, static_segment : [] },
5067
+ token
5068
+ });
5069
+
5070
+ id = created?.id;
5071
+
5072
+ }
5073
+
5074
+ if( ! id ) return { message : 'Mailchimp returned no tag id.', skipped : true };
5075
+
5076
+ // THE AUDIENCE'S WEB ID, which is what the merchant's admin url is
5077
+ // keyed on — the api id in `settings.audience` does not address a page.
5078
+ // Read here rather than at page load, where the data centre is not
5079
+ // decrypted and a vendor round-trip would be on the critical path.
5080
+ const audienceDetail = await api( '/lists/' + audience + '?fields=web_id', { dc : settings?.dc, fetcher, token });
5081
+
5082
+ return {
5083
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
5084
+ // coalescing job id, so the last thing this does is look again.
5085
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
5086
+ events : [ {
5087
+ event : 'organization.segments',
5088
+ payload : { id : segment.id },
5089
+ room : 'organization.' + connection?.organization
5090
+ } ],
5091
+ message : 'Mailchimp is carrying this segment as the tag "' + name + '".',
5092
+ writes : segmentRowWrites({
5093
+ connection,
5094
+ data : { ...connection, settings },
5095
+ manifest,
5096
+ row : { id, type : 'tag', webId : audienceDetail?.web_id },
5097
+ segment
5098
+ })
5099
+ };
5100
+
5101
+ },
5102
+
5103
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
5104
+ // whole pair exists to stop — every member would keep a label for a
5105
+ // segment that no longer exists.
5106
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
5107
+
5108
+ // NO RE-READ HERE. The segment is already deleted — the pre-image is
5109
+ // the only copy there is, and it carries the rows naming what to
5110
+ // remove.
5111
+ const segment = context?.segment;
5112
+ const existing = segmentRowFor({ connection, segment });
5113
+
5114
+ if( ! existing?.id ) return { message : 'Mailchimp was never carrying this segment.', skipped : true };
5115
+
5116
+ try {
5117
+
5118
+ await api( '/lists/' + settings?.audience + '/segments/' + existing.id, {
5119
+ dc : settings?.dc,
5120
+ fetcher,
5121
+ method : 'DELETE',
5122
+ token
5123
+ });
5124
+
5125
+ } catch ( error ){
5126
+
5127
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
5128
+ // retrying a 404 twice more achieves nothing.
5129
+ //
5130
+ // Mailchimp writes down no 404 for this call — Delete segment
5131
+ // declares a 204 and a generic problem detail and nothing else
5132
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/Segments/Instance.json,
5133
+ // fetched 2026-09-11) — so this follows from their general error
5134
+ // table rather than from anything documented about deleting a
5135
+ // segment. It is still the right handling: a segment that is not
5136
+ // there is the outcome we wanted.
5137
+ if( error.status !== 404 ) throw error;
5138
+
5139
+ }
5140
+
5141
+ return {
5142
+ message : 'Mailchimp is no longer carrying this segment.',
5143
+ writes : segmentRowRemoveWrites({ connection, segment })
5144
+ };
5145
+
5146
+ },
5147
+
5148
+ // Drawbridge-side membership belongs to the private manifest.
5149
+ sync : false
5150
+
5151
+ },
5152
+ // Declined for the same reason as `email` above: Drawbridge sends its own
5153
+ // notification SMS, and a vendor answering this would be a second sender.
4282
5154
  sms : false,
4283
5155
  inbound : false,
4284
5156
  lifecycle : false,
@@ -4358,6 +5230,16 @@ var mailchimp = {
4358
5230
  'MAILCHIMP_OAUTH_CLIENT_ID',
4359
5231
  'MAILCHIMP_OAUTH_CLIENT_SECRET'
4360
5232
  ],
5233
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5234
+ review : {
5235
+ api : 'https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/',
5236
+ dashboard : 'https://mailchimp.com/help/manage-tags/',
5237
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
5238
+ // account-wide — so there is nothing to request and nothing to re-consent.
5239
+ scopes : false,
5240
+ content : '2026-09-11',
5241
+ verified : null
5242
+ },
4361
5243
  slug : 'mailchimp',
4362
5244
  // A grant with no audience chosen is authenticated and useless — the sync has
4363
5245
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -4412,6 +5294,34 @@ var mailchimp = {
4412
5294
 
4413
5295
  })
4414
5296
 
5297
+ },
5298
+
5299
+ segment : {
5300
+
5301
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
5302
+ // these fire from the segment's own lifecycle, not from a workflow
5303
+ // somebody assembled.
5304
+ //
5305
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
5306
+ // which is what lets a vendor arrive with its own without a queue edit.
5307
+ register : () => ({
5308
+ description : 'Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.',
5309
+ hook : 'segment.register',
5310
+ key : 'Mailchimp Segment Register',
5311
+ queue : 'connection',
5312
+ system : true,
5313
+ trigger : { event : 'segment.register', type : 'event' }
5314
+ }),
5315
+
5316
+ remove : () => ({
5317
+ description : 'Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.',
5318
+ hook : 'segment.remove',
5319
+ key : 'Mailchimp Segment Remove',
5320
+ queue : 'connection',
5321
+ system : true,
5322
+ trigger : { event : 'segment.remove', type : 'event' }
5323
+ })
5324
+
4415
5325
  }
4416
5326
 
4417
5327
  },
@@ -4429,7 +5339,23 @@ var mailchimp = {
4429
5339
  }
4430
5340
  ]
4431
5341
  ),
4432
- title : 'Mailchimp'
5342
+ title : 'Mailchimp',
5343
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
5344
+ // the web_id field is "The ID used in the Mailchimp web application. View this
5345
+ // list in your Mailchimp account at
5346
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
5347
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
5348
+ // 2026-09-11).
5349
+ //
5350
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
5351
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
5352
+ // click short rather than guessing at one that could break silently.
5353
+ urls : {
5354
+ segment : ( row, data ) => ( data?.settings?.dc && row?.webId
5355
+ ? 'https://' + data.settings.dc + '.admin.mailchimp.com/lists/members/?id=' + row.webId
5356
+ : null
5357
+ )
5358
+ }
4433
5359
  };
4434
5360
 
4435
5361
  // Shopify, exported from the brand kit and left as authored — the fills are the
@@ -4503,6 +5429,61 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
4503
5429
  // types them into a checkout.
4504
5430
  const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
4505
5431
 
5432
+ // WHY A DISCOUNT CANNOT BACK AN ISSUED CODE, as a sentence for the merchant or
5433
+ // null when nothing is wrong. Every rule here is about the code we are about to
5434
+ // mint being redeemable by an ENTRANT we picked — which is a narrower question
5435
+ // than whether the discount is valid in general, and the reason a discount that
5436
+ // looks fine in Shopify admin can still be the wrong one to choose.
5437
+ //
5438
+ // Field semantics cited in drawbridge-shopify's discountsQuery.
5439
+ const blockedReason = ( discount ) => {
5440
+
5441
+ if( discount?.status === 'EXPIRED' ) return 'This discount has expired.';
5442
+
5443
+ // The union's "anyone" member. Everything else — named customers, a saved
5444
+ // segment, a market — restricts who may redeem, and a code we mint for an
5445
+ // entrant who is not on that list is a code that fails at checkout. Creating
5446
+ // the customer would not fix it: joining a discount's eligibility list is a
5447
+ // separate write we do not make.
5448
+ const buyers = discount?.context?.__typename;
5449
+
5450
+ if( buyers && buyers !== 'DiscountBuyerSelectionAll' ){
5451
+
5452
+ return 'This discount is limited to specific buyers in Shopify, so a code issued to an entrant won\'t work. Set it to all customers to use it here.';
5453
+
5454
+ }
5455
+ // Documented as possibly lagging the true count, so this can only ever
5456
+ // under-report — count >= limit means genuinely exhausted.
5457
+ if( typeof discount?.usageLimit === 'number' && discount.usageLimit > 0
5458
+ && ( discount?.asyncUsageCount || 0 ) >= discount.usageLimit ){
5459
+
5460
+ return 'This discount has reached its total usage limit.';
5461
+
5462
+ }
5463
+ return null;
5464
+
5465
+ };
5466
+
5467
+ // Usable, but about to behave in a way the merchant did not ask for. Separate
5468
+ // from blockedReason because the answer here is "go ahead, knowing this" — a
5469
+ // scheduled discount is the normal way to set up a campaign in advance, and
5470
+ // refusing it would be wrong.
5471
+ const discountWarning = ( discount ) => {
5472
+
5473
+ if( discount?.status === 'SCHEDULED' ){
5474
+
5475
+ return 'This discount hasn\'t started yet, so codes issued before it does won\'t work until then.';
5476
+
5477
+ }
5478
+ // One redemption per person, which is usually intended — but it is the
5479
+ // difference between a code that can be forwarded and one that cannot, and
5480
+ // merchants do not expect a per-entrant code to also be per-person capped.
5481
+ if( discount?.appliesOncePerCustomer ) return 'Each customer can use this discount only once.';
5482
+
5483
+ return null;
5484
+
5485
+ };
5486
+
4506
5487
  // THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
4507
5488
  // billing event bills or is silently ingested as a plain custom event. It must
4508
5489
  // equal, case-sensitively, the meter HANDLE configured on the app's plan in
@@ -4603,7 +5584,7 @@ var shopify = {
4603
5584
  description : [
4604
5585
  'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
4605
5586
  'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
4606
- 'On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the resources.'
5587
+ 'Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app\'s own webhooks, which Shopify applies when the app is installed.'
4607
5588
  ],
4608
5589
  errors : {
4609
5590
  connect : {
@@ -4617,7 +5598,7 @@ var shopify = {
4617
5598
  'Open the Drawbridge listing on the Shopify App Store.',
4618
5599
  'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
4619
5600
  'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
4620
- 'Come back here — the connections list updates on its own once the install lands.'
5601
+ 'Come back here — the connections list updates on its own once the install finishes.'
4621
5602
  ],
4622
5603
  // Names where the link GOES rather than what it does: installing happens on
4623
5604
  // the App Store listing, and the dashboard must never imply a store can be
@@ -5140,14 +6121,27 @@ var shopify = {
5140
6121
  ? { domain : connection.source.domain, id : connection.source.id }
5141
6122
  : undefined;
5142
6123
 
6124
+ // EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
6125
+ //
6126
+ // A redemption used to write only a redemption row, which meant the
6127
+ // money existed on the Redemptions page and nowhere else: contact
6128
+ // totals are summed from the ORDER collection, so a real purchase by a
6129
+ // known entrant contributed nothing to their revenue and was invisible
6130
+ // to every revenue segment. `type` keeps the two kinds apart for the
6131
+ // figures that must stay causal (the fee, the Revenue page's
6132
+ // conversion column) without splitting the source of truth in two.
6133
+ const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
6134
+
5143
6135
  // MINTED HERE, because the redemption names its order and the usage
5144
6136
  // job names both — a description cannot read a write's result, so the
5145
- // id exists before either does.
5146
- const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
6137
+ // id exists before either does. Minted for the redemption too, so the
6138
+ // order can name it back.
6139
+ const orderDocId = existingOrder?.id || ( createsOrder ? mintId() : null );
6140
+ const redemptionDocId = discount ? mintId() : null;
5147
6141
 
5148
6142
  const writes = [];
5149
6143
 
5150
- if( isConversion && ! backfill ){
6144
+ if( createsOrder ){
5151
6145
 
5152
6146
  writes.push({
5153
6147
  collection : 'order',
@@ -5169,8 +6163,12 @@ var shopify = {
5169
6163
  provider : { id : String( orderId ), slug : 'shopify' },
5170
6164
  purchasedAt,
5171
6165
  rate,
6166
+ // Null on a conversion that matched no code of ours; the
6167
+ // backfill branch below sets it when one arrives later.
6168
+ redemption : redemptionDocId,
5172
6169
  source,
5173
- status : 'completed'
6170
+ status : 'completed',
6171
+ type : isConversion ? 'conversion' : 'redemption'
5174
6172
  },
5175
6173
  operation : 'create'
5176
6174
  });
@@ -5179,7 +6177,14 @@ var shopify = {
5179
6177
 
5180
6178
  writes.push({
5181
6179
  collection : 'usage',
5182
- data : { $inc : { 'totals.revenue' : gross } },
6180
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
6181
+ // conversion revenue and is the figure the fee is charged
6182
+ // against, so redemption money gets its own key rather than
6183
+ // changing what an existing number means.
6184
+ data : { $inc : isConversion
6185
+ ? { 'totals.revenue' : gross }
6186
+ : { 'totals.redemptionRevenue' : gross }
6187
+ },
5183
6188
  operation : 'update',
5184
6189
  query : { id : org.usage }
5185
6190
  });
@@ -5190,7 +6195,15 @@ var shopify = {
5190
6195
 
5191
6196
  writes.push({
5192
6197
  collection : 'lead',
5193
- data : { $inc : { 'totals.orders' : 1 } },
6198
+ // Same grouped shape the contact carries, so a lead and the
6199
+ // contact built from it cannot be read two different ways.
6200
+ data : { $inc : {
6201
+ 'totals.orders.total' : 1,
6202
+ ...( isConversion
6203
+ ? { 'totals.orders.conversions' : 1 }
6204
+ : { 'totals.orders.redemptions' : 1 }
6205
+ )
6206
+ } },
5194
6207
  operation : 'update',
5195
6208
  options : { bypassDocumentValidation : true },
5196
6209
  query : { id : leadId }
@@ -5213,6 +6226,7 @@ var shopify = {
5213
6226
  customer,
5214
6227
  discount,
5215
6228
  gross,
6229
+ id : redemptionDocId,
5216
6230
  lead : leadId,
5217
6231
  order : orderDocId,
5218
6232
  organization : campaignOrganization,
@@ -5248,6 +6262,23 @@ var shopify = {
5248
6262
 
5249
6263
  }
5250
6264
 
6265
+ // THE BACKFILL LEG: a conversion recorded earlier, matched to one of
6266
+ // our codes now. The order already exists, so nothing above created
6267
+ // it and nothing has told it which redemption it belongs to — this
6268
+ // is the only write that closes that link. Skipped when the order
6269
+ // was created in this same run, because it was minted carrying the
6270
+ // id already.
6271
+ if( backfill && orderDocId && redemptionDocId ){
6272
+
6273
+ writes.push({
6274
+ collection : 'order',
6275
+ data : { $set : { redemption : redemptionDocId } },
6276
+ operation : 'update',
6277
+ query : { id : orderDocId }
6278
+ });
6279
+
6280
+ }
6281
+
5251
6282
  }
5252
6283
 
5253
6284
  // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
@@ -6035,10 +7066,24 @@ var shopify = {
6035
7066
  // The GLOBAL id is what Shopify returns and the bare id is what a
6036
7067
  // picker stores, which is why the tail is taken here rather than by
6037
7068
  // each caller that happened to remember.
6038
- items : ( discounts?.edges || [] ).map( ( edge ) => ({
6039
- id : String( edge?.node?.id || '' ).split( '/' ).pop(),
6040
- title : edge?.node?.codeDiscount?.title
6041
- }) ),
7069
+ items : ( discounts?.edges || [] ).map( ( edge ) => {
7070
+
7071
+ const node = edge?.node?.codeDiscount || {};
7072
+
7073
+ return {
7074
+ // Null when the discount can be used, a sentence when it cannot.
7075
+ // The picker greys the row and shows this instead of hiding it:
7076
+ // a discount the merchant can see in Shopify admin, missing here
7077
+ // with no explanation, reads as a bug in us.
7078
+ blocked : blockedReason( node ),
7079
+ id : String( edge?.node?.id || '' ).split( '/' ).pop(),
7080
+ // Usable, but not in the way the merchant probably expects.
7081
+ // Shown beside the row without stopping them.
7082
+ warning : discountWarning( node ),
7083
+ title : node.title
7084
+ };
7085
+
7086
+ }),
6042
7087
  pageInfo : {
6043
7088
  endCursor : discounts?.pageInfo?.endCursor || null,
6044
7089
  hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
@@ -6054,24 +7099,6 @@ var shopify = {
6054
7099
  },
6055
7100
  icon,
6056
7101
  inbound,
6057
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
6058
- //
6059
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
6060
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
6061
- // through, which is the arrangement these manifests exist to remove.
6062
- //
6063
- // Undefined until a shop is linked, so the Manage button only appears on a
6064
- // connected connection. The app handle is NAMED by `requires` and read from
6065
- // the env the resolver passes, never from process.env here.
6066
- manage : ( data, env ) => {
6067
-
6068
- const shop = data?.shop || data?.settings?.domain;
6069
-
6070
- return shop
6071
- ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
6072
- : undefined;
6073
-
6074
- },
6075
7102
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
6076
7103
  // what an admin types on the provider screen. The four names below are exactly
6077
7104
  // what `requires` gates on, which is the point of declaring them together: a
@@ -6117,6 +7144,14 @@ var shopify = {
6117
7144
  'SHOPIFY_APP_LISTING_URL',
6118
7145
  'SHOPIFY_APP_HANDLE'
6119
7146
  ],
7147
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
7148
+ review : {
7149
+ api : 'https://shopify.dev/docs/api/admin-graphql',
7150
+ dashboard : 'https://help.shopify.com/en/manual/apps',
7151
+ scopes : 'https://shopify.dev/docs/api/usage/access-scopes',
7152
+ content : '2026-09-11',
7153
+ verified : null
7154
+ },
6120
7155
  slug : 'shopify',
6121
7156
  // The install is the whole configuration — Shopify hands back the shop and
6122
7157
  // there is nothing further to choose. `shop` absent means the install did not
@@ -6209,8 +7244,11 @@ var shopify = {
6209
7244
  },
6210
7245
 
6211
7246
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
6212
- // in the builder, so they carry no trigger and no usage. Declared because
6213
- // the routing table and the system-workflow descriptions both read here.
7247
+ // in the builder, so they carry no usage. These two are fired by a webhook
7248
+ // arriving rather than by a workflow trigger, so they name none either —
7249
+ // and naming none is what stops a workflow being provisioned for them.
7250
+ // Declared because the routing table and the system-workflow descriptions
7251
+ // both read here.
6214
7252
  order : {
6215
7253
  record : () => ({
6216
7254
  description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
@@ -6246,7 +7284,8 @@ var shopify = {
6246
7284
  hook : 'lifecycle.health',
6247
7285
  key : 'Shopify Connection Health',
6248
7286
  queue : 'connection',
6249
- system : true
7287
+ system : true,
7288
+ trigger : { event : 'day', type : 'schedule' }
6250
7289
  })
6251
7290
  },
6252
7291
 
@@ -6313,7 +7352,34 @@ var shopify = {
6313
7352
  : []
6314
7353
  )
6315
7354
  ],
6316
- title : 'Shopify'
7355
+ title : 'Shopify',
7356
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
7357
+ // here rather than at the top level so a second link (a product, an order)
7358
+ // is a key in this object instead of a new manifest key nobody agreed on.
7359
+ //
7360
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
7361
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
7362
+ // vendor runs through, which is the arrangement these manifests exist to
7363
+ // remove.
7364
+ //
7365
+ // Never projected: the api composes connect.manage from it, and
7366
+ // resolveConnection drops the object, because a url built from settings is
7367
+ // built where the settings are already decrypted.
7368
+ urls : {
7369
+ // Undefined until a shop is linked, so the Manage button only appears on a
7370
+ // connected connection. The app handle is NAMED by `requires` and read from
7371
+ // the env its caller passes — the api's resolve() hands it the stored
7372
+ // credentials, never process.env.
7373
+ manage : ( data, env ) => {
7374
+
7375
+ const shop = data?.shop || data?.settings?.domain;
7376
+
7377
+ return shop
7378
+ ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
7379
+ : undefined;
7380
+
7381
+ }
7382
+ }
6317
7383
  };
6318
7384
 
6319
7385
  // Webhooks — the only connection with no third party behind it. Connecting
@@ -6370,7 +7436,7 @@ var webhook = {
6370
7436
  content : {
6371
7437
  confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
6372
7438
  description : [
6373
- 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
7439
+ 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
6374
7440
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
6375
7441
  ],
6376
7442
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
@@ -6496,6 +7562,9 @@ var webhook = {
6496
7562
  // Gated on the encryption secret: without it the signing secret could not be
6497
7563
  // stored safely, so the connection must not be offered at all.
6498
7564
  requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
7565
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
7566
+ // to link to and no scope to request: connecting mints a secret.
7567
+ review : false,
6499
7568
  // Outbound only. inbound.* is false because the direction is the point: we
6500
7569
  // sign and POST to the merchant's endpoint, they never call us. Every other
6501
7570
  // false follows from there being no third party to authenticate against —
@@ -7490,7 +8559,7 @@ const redactSettings = ({ slug, settings }) => {
7490
8559
  const publicConnectionKeys = Object.freeze([
7491
8560
  'actions',
7492
8561
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
7493
- // auth.type, content.redirect and the manifest's manage() — the client reads
8562
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
7494
8563
  // connect.type to choose entered-vs-installed, connect.redirect for the App
7495
8564
  // Store link, connect.manage for the admin deep link. It was dropped from
7496
8565
  // this list when the manifests stopped declaring it, which stripped the
@@ -7582,7 +8651,15 @@ const resolveConnection = ( item, data, env = {} ) => {
7582
8651
  // answer baked a Pending badge into Klaviyo, Mailchimp and Attentive
7583
8652
  // cards nobody had connected. Status belongs to the caller's own
7584
8653
  // read-time pass (api's applyStatus), never to projection.
7585
- .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'status', 'steps', 'supports' ].includes( key ) )
8654
+ // `urls` is dropped for the same reason `provider` is: it holds raw
8655
+ // functions, and a url built from stored settings must be built where
8656
+ // those settings are already decrypted — the api calls urls.manage()
8657
+ // itself and composes the result into connect.
8658
+ //
8659
+ // `review` is dropped because it is engineering metadata — which docs
8660
+ // were read, on what day — with no client that reads it. Left in, it
8661
+ // would ride on every connection response a merchant's browser loads.
8662
+ .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'review', 'status', 'steps', 'supports', 'urls' ].includes( key ) )
7586
8663
  .map( ( [ key, value ] ) => [
7587
8664
  key,
7588
8665
  ( typeof value === 'function' ? value( data, env ) : value )