@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.
@@ -178,7 +178,20 @@ var HOOKS = Object.freeze({
178
178
  "digest"
179
179
  ]),
180
180
  sms: Object.freeze(["send"]),
181
- segment: Object.freeze(["sync"]),
181
+ segment: Object.freeze([
182
+ // Make this segment's object exist at the vendor, carrying the segment's
183
+ // current title, and describe the row that points at it. IDEMPOTENT: the
184
+ // same call creates it, renames it after an edit, and backfills a segment
185
+ // that predates the connection — so one hook serves every path and there
186
+ // is no create-vs-update branch to keep in step.
187
+ "register",
188
+ // Remove the vendor object this connection's row points at. Called with
189
+ // the pre-image on a segment delete, because by then the document is gone.
190
+ "remove",
191
+ // Recalculate Drawbridge-side membership. Private to the drawbridge
192
+ // manifest; a vendor does not own who is in a Drawbridge segment.
193
+ "sync"
194
+ ]),
182
195
  // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
183
196
  // The Webhooks connection is the only thing here with no third party behind
184
197
  // it, and the destination is per STEP rather than per connection.
@@ -287,6 +300,8 @@ var STEPS = Object.freeze({
287
300
  "email.digest": "Digest",
288
301
  "email.notify": "Notification",
289
302
  "email.send": "Send email",
303
+ "segment.register": "Register segment",
304
+ "segment.remove": "Remove segment",
290
305
  "segment.sync": "Sync segment",
291
306
  "sms.send": "Send SMS",
292
307
  "webhook.send": "Send webhook"
@@ -422,7 +437,16 @@ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
422
437
  ...tokens.expiresIn && {
423
438
  expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
424
439
  },
425
- ...tokens.scope && { scope: tokens.scope }
440
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
441
+ // authorization_code response and documents no response body at all for the
442
+ // refresh grant, so taking the minted value alone drops the stored one. That
443
+ // matters because `scope` is load-bearing: the segment hooks gate on
444
+ // `segments:write` and answer `skipped` when it is absent, so a connection
445
+ // that dropped it disables its whole segment half without failing anything
446
+ // and shows a reconnect task that reconnecting has already fixed.
447
+ ...(tokens.scope || existing.scope) && {
448
+ scope: tokens.scope || existing.scope
449
+ }
426
450
  });
427
451
  var accessToken = async ({
428
452
  clientId,
@@ -485,6 +509,98 @@ var detectCountry = (value) => {
485
509
  }
486
510
  };
487
511
 
512
+ // lib/connections/segment-rows.js
513
+ var import_node_crypto2 = require("crypto");
514
+ var row = ({ connection: connection2, data: data2, manifest, row: described }) => {
515
+ var _a, _b;
516
+ return {
517
+ connection: connection2 == null ? void 0 : connection2.id,
518
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
519
+ // strings, and one type in the schema is one comparison in the $or below.
520
+ id: String(described == null ? void 0 : described.id),
521
+ slug: connection2 == null ? void 0 : connection2.slug,
522
+ type: described == null ? void 0 : described.type,
523
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
524
+ // The url is built HERE, while the settings are decrypted and the vendor
525
+ // facts are in hand — an api reading the row later has neither.
526
+ url: ((_b = (_a = manifest == null ? void 0 : manifest.urls) == null ? void 0 : _a.segment) == null ? void 0 : _b.call(_a, { ...described, id: String(described == null ? void 0 : described.id) }, data2)) || null
527
+ };
528
+ };
529
+ var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
530
+ const built = row({ connection: connection2, data: data2, manifest, row: described });
531
+ return [
532
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
533
+ // add nothing rather than a duplicate row for one connection.
534
+ {
535
+ collection: "segment",
536
+ data: { $push: { connections: built } },
537
+ operation: "update",
538
+ query: {
539
+ id: segment == null ? void 0 : segment.id,
540
+ "connections.connection": { $ne: connection2 == null ? void 0 : connection2.id }
541
+ }
542
+ },
543
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
544
+ // of its three mutable fields disagrees, so the steady state — the same
545
+ // vendor object, the same url — matches nothing and writes nothing.
546
+ {
547
+ collection: "segment",
548
+ data: { $set: { "connections.$": built } },
549
+ operation: "update",
550
+ query: {
551
+ id: segment == null ? void 0 : segment.id,
552
+ connections: {
553
+ $elemMatch: {
554
+ connection: connection2 == null ? void 0 : connection2.id,
555
+ $or: [
556
+ { id: { $ne: built.id } },
557
+ { type: { $ne: built.type } },
558
+ { url: { $ne: built.url } }
559
+ ]
560
+ }
561
+ }
562
+ }
563
+ }
564
+ ];
565
+ };
566
+ var segmentRowRemoveWrites = ({ connection: connection2, segment }) => [
567
+ {
568
+ collection: "segment",
569
+ data: { $pull: { connections: { connection: connection2 == null ? void 0 : connection2.id } } },
570
+ operation: "update",
571
+ query: { id: segment == null ? void 0 : segment.id }
572
+ }
573
+ ];
574
+ var segmentRowFor = ({ connection: connection2, segment }) => ((segment == null ? void 0 : segment.connections) || []).find((entry) => (entry == null ? void 0 : entry.connection) === (connection2 == null ? void 0 : connection2.id));
575
+ var currentSegment = async ({ read, segment }) => {
576
+ if (!(read == null ? void 0 : read.get) || !(segment == null ? void 0 : segment.id)) return segment;
577
+ return read.get({
578
+ collection: "segment",
579
+ query: { id: segment.id }
580
+ });
581
+ };
582
+ var driftEnqueues = async ({ applied, read, segment, workflow }) => {
583
+ if (!(workflow == null ? void 0 : workflow.id)) return [];
584
+ const fresh = await currentSegment({ read, segment });
585
+ if (!(fresh == null ? void 0 : fresh.id) || fresh.title === applied) return [];
586
+ return [{
587
+ data: {
588
+ triggerData: {
589
+ organization: fresh.organization || (workflow == null ? void 0 : workflow.organization),
590
+ segment: fresh
591
+ },
592
+ workflowId: workflow == null ? void 0 : workflow.id
593
+ },
594
+ name: "execute",
595
+ options: {
596
+ jobId: "workflow.insert.execute." + (workflow == null ? void 0 : workflow.id) + ".segment.register." + fresh.id + ".drift." + Date.now() + "." + (0, import_node_crypto2.randomUUID)().slice(0, 8),
597
+ removeOnComplete: true,
598
+ removeOnFail: true
599
+ },
600
+ queue: "workflow"
601
+ }];
602
+ };
603
+
488
604
  // lib/connections/providers/attentive.js
489
605
  var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
490
606
  const response = await fetcher("https://api.attentivemobile.com" + path, {
@@ -537,7 +653,7 @@ var attentive_default2 = {
537
653
  // has to say so rather than let them believe otherwise.
538
654
  confirm: "Disconnecting removes Drawbridge's stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge \u2014 neither list is deleted.",
539
655
  description: [
540
- "Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment \u2014 subscribed for marketing and added to the segment you choose.",
656
+ "This connection syncs the contacts your campaigns collect into your Attentive account \u2014 subscribed for marketing, and added to the segment you choose.",
541
657
  "You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
542
658
  "Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
543
659
  "Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs."
@@ -579,10 +695,9 @@ var attentive_default2 = {
579
695
  }
580
696
  ],
581
697
  group: "contacts",
582
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
583
- // nothing else is built yet, because subscriber sync has not shipped. Every
584
- // false here is "not yet" rather than "never" — when the sync lands, probe
585
- // and contacts.sync are the first to flip.
698
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
699
+ // a paragraph up here that goes stale the moment one of them is implemented
700
+ // which is exactly what happened to the note this replaces.
586
701
  hooks: {
587
702
  auth: {
588
703
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
@@ -595,7 +710,7 @@ var attentive_default2 = {
595
710
  // nobody re-derives it. Klaviyo's connect reads the account name back so
596
711
  // the card is not blank; Attentive's card stays blank. There IS an
597
712
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
598
- // on docs.attentive.com/pages/authentication/ as returning "information
713
+ // on docs.attentive.com/docs/authentication as returning "information
599
714
  // specific to your company" — but its RESPONSE SCHEMA is published
600
715
  // nowhere we can read: the docs show the curl and no body. Reading
601
716
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -762,7 +877,62 @@ var attentive_default2 = {
762
877
  products: false,
763
878
  promotions: false
764
879
  },
765
- segment: false,
880
+ segment: {
881
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
882
+ // one with an externalId we choose (docs.attentive.com/reference/
883
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
884
+ // required, `externalId` optional and "auto-generated if not supplied"),
885
+ // which would give a real per-segment object — but it takes
886
+ // segments:write, and scopes ride on the app registration, which does not
887
+ // exist yet.
888
+ //
889
+ // So the row points at the connection-level segment the merchant chose,
890
+ // `type` says so, and turning this into a per-segment object later is a
891
+ // change to this file and nothing else: create with
892
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
893
+ // update and archive endpoints are BOTH keyed by external id
894
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
895
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
896
+ // already hold addresses every one of the three calls.
897
+ //
898
+ // NO DRIFT CHECK, unlike the other two: the row points at the
899
+ // connection's own segment and the link is the index page, so nothing
900
+ // here depends on the Drawbridge segment's title — a rename has nothing
901
+ // to apply and nothing to race with. That comes back with the
902
+ // per-segment object.
903
+ //
904
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
905
+ // the simplest of the three registers and therefore the one the next
906
+ // vendor gets copied from — one job id serves four dispatch sites, so a
907
+ // copy that trusts context.segment applies whichever trigger data won
908
+ // the race, at a vendor where the title does matter.
909
+ register: async ({ connection: connection2, context, manifest, settings }, { read } = {}) => {
910
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
911
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
912
+ if (!(settings == null ? void 0 : settings.segment)) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
913
+ return {
914
+ events: [{
915
+ event: "organization.segments",
916
+ payload: { id: segment.id },
917
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
918
+ }],
919
+ message: "Contacts in this segment are added to the Attentive segment chosen on this connection.",
920
+ writes: segmentRowWrites({
921
+ connection: connection2,
922
+ data: { ...connection2, settings },
923
+ manifest,
924
+ row: { id: settings.segment, type: "segment" },
925
+ segment
926
+ })
927
+ };
928
+ },
929
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
930
+ // and it is where every Drawbridge segment's contacts go — removing it
931
+ // because one Drawbridge segment was deleted would empty the others.
932
+ remove: false,
933
+ // Drawbridge-side membership belongs to the private manifest.
934
+ sync: false
935
+ },
766
936
  sms: false,
767
937
  webhook: false
768
938
  },
@@ -791,6 +961,21 @@ var attentive_default2 = {
791
961
  "ATTENTIVE_OAUTH_CLIENT_ID",
792
962
  "ATTENTIVE_OAUTH_CLIENT_SECRET"
793
963
  ],
964
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
965
+ review: {
966
+ api: "https://docs.attentive.com/reference/listsegments",
967
+ dashboard: "https://docs.attentive.com/docs/segments",
968
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
969
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
970
+ // privacy_requests:write — and says nothing about segments:read or
971
+ // segments:write, which the segments API this manifest calls does take.
972
+ // The header at the top of this file carries that distinction; it is
973
+ // repeated here so a reviewer following the link is not misled by what the
974
+ // table omits (fetched 2026-09-11).
975
+ scopes: "https://docs.attentive.com/docs/authentication",
976
+ content: "2026-09-11",
977
+ verified: null
978
+ },
794
979
  slug: "attentive",
795
980
  // A consent with no segment chosen is authenticated and inert — the sync needs
796
981
  // somewhere to put people — so the card says Pending rather than Active over
@@ -828,6 +1013,24 @@ var attentive_default2 = {
828
1013
  triggers: ["lead.insert", "segment.contact.add"],
829
1014
  usage: { actions: 1 }
830
1015
  })
1016
+ },
1017
+ segment: {
1018
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
1019
+ // this fires from the segment's own lifecycle, not from a workflow
1020
+ // somebody assembled. The trigger is declared here rather than hard-coded
1021
+ // in drawbridge-sync.
1022
+ //
1023
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
1024
+ // declined, and build() refuses a step pointing at a hook this vendor does
1025
+ // not implement — so the two are one decision, enforced at import.
1026
+ register: () => ({
1027
+ description: "Records which Attentive segment a Drawbridge segment's contacts are added to.",
1028
+ hook: "segment.register",
1029
+ key: "Attentive Segment Register",
1030
+ queue: "connection",
1031
+ system: true,
1032
+ trigger: { event: "segment.register", type: "event" }
1033
+ })
831
1034
  }
832
1035
  },
833
1036
  // WHY, in the merchant's words, and what to do about it.
@@ -844,14 +1047,28 @@ var attentive_default2 = {
844
1047
  }
845
1048
  ];
846
1049
  },
847
- title: "Attentive"
1050
+ title: "Attentive",
1051
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1052
+ // only identifier we hold is the API's externalId, which their UI may not
1053
+ // path by — so this lands on the list, where the merchant finds it by name.
1054
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1055
+ //
1056
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1057
+ // segment url carries. What is on record is that the segments area lives at
1058
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1059
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1060
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1061
+ // walk-through confirms this against a real account before promote.
1062
+ urls: {
1063
+ segment: () => "https://ui.attentivemobile.com/segments/all"
1064
+ }
848
1065
  };
849
1066
 
850
1067
  // lib/connections/providers/drawbridge.js
851
- var import_node_crypto3 = require("crypto");
1068
+ var import_node_crypto4 = require("crypto");
852
1069
 
853
1070
  // lib/connections/inbound.js
854
- var import_node_crypto2 = require("crypto");
1071
+ var import_node_crypto3 = require("crypto");
855
1072
  var verifySignature = ({ body, descriptor, headers, secret }) => {
856
1073
  if (!secret) {
857
1074
  throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
@@ -860,10 +1077,10 @@ var verifySignature = ({ body, descriptor, headers, secret }) => {
860
1077
  if (!provided) {
861
1078
  throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
862
1079
  }
863
- const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
1080
+ const digest = (0, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
864
1081
  const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
865
1082
  const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
866
- if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
1083
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
867
1084
  throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
868
1085
  }
869
1086
  return JSON.parse(body.toString());
@@ -888,7 +1105,7 @@ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
888
1105
  throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
889
1106
  }
890
1107
  const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
891
- const verified = (0, import_node_crypto2.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
1108
+ const verified = (0, import_node_crypto3.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
892
1109
  if (!verified) {
893
1110
  throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
894
1111
  }
@@ -1523,10 +1740,10 @@ var free = {
1523
1740
  };
1524
1741
  var plans = {
1525
1742
  DB00002: {
1526
- // A verified sending domain is a PAID capability: free plans cannot send
1527
- // lead-facing email at all (the send path gates on an active
1528
- // subscription), so granting it there would offer a domain that can
1529
- // never send from.
1743
+ // A verified sending domain is a PAID capability. Every plan sends
1744
+ // lead-facing email from the platform address — the send is billed as an
1745
+ // action, so the allowance is the entitlement and sending from your own
1746
+ // domain is what the paid tiers add on top.
1530
1747
  features: all.features([organization.networking.key, organization.members.key]),
1531
1748
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
1532
1749
  marketing: {
@@ -1964,7 +2181,7 @@ var drawbridge_default2 = {
1964
2181
  content: {
1965
2182
  confirm: "This connection is part of Drawbridge and cannot be disconnected.",
1966
2183
  description: [
1967
- "Drawbridge sends your notification 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."
2184
+ "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
1968
2185
  ],
1969
2186
  excerpt: "The steps Drawbridge runs itself.",
1970
2187
  guide: [
@@ -2130,16 +2347,6 @@ var drawbridge_default2 = {
2130
2347
  const request2 = { to };
2131
2348
  const { ok: sendable } = await canSend({ channel: "email", to });
2132
2349
  if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
2133
- const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
2134
- const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
2135
- if ((subscription == null ? void 0 : subscription.status) !== "active") {
2136
- return {
2137
- message: "Organization has no active subscription \u2014 workflow-step email skipped.",
2138
- request: request2,
2139
- response: { skipped: true },
2140
- skipped: true
2141
- };
2142
- }
2143
2350
  return {
2144
2351
  message: "Email queued for delivery to " + to + ".",
2145
2352
  request: request2,
@@ -2272,9 +2479,9 @@ var drawbridge_default2 = {
2272
2479
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
2273
2480
  const params = new URLSearchParams(String(body || ""));
2274
2481
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
2275
- const expected = (0, import_node_crypto3.createHmac)("sha1", secret).update(signed).digest("base64");
2482
+ const expected = (0, import_node_crypto4.createHmac)("sha1", secret).update(signed).digest("base64");
2276
2483
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2277
- const matches = expected.length === provided.length && (0, import_node_crypto3.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2484
+ const matches = expected.length === provided.length && (0, import_node_crypto4.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2278
2485
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2279
2486
  return Object.fromEntries(params);
2280
2487
  }
@@ -2287,6 +2494,11 @@ var drawbridge_default2 = {
2287
2494
  promotions: false
2288
2495
  },
2289
2496
  segment: {
2497
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2498
+ // vendor, and this manifest has no vendor behind it — the three that do
2499
+ // implement these.
2500
+ register: false,
2501
+ remove: false,
2290
2502
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2291
2503
  // contact in an organization against every segment, which is too much for
2292
2504
  // one job, so it returns chunks and the shell defers completion.
@@ -2562,6 +2774,33 @@ var drawbridge_default2 = {
2562
2774
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2563
2775
  // empty the moment this was added.
2564
2776
  requires: [],
2777
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
2778
+ // manifest, so `false` would be a lie about which reads were made.
2779
+ //
2780
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
2781
+ // citation here would evidence one of them and read as though it covered all
2782
+ // three, which is the omission this key exists to catch.
2783
+ review: {
2784
+ api: {
2785
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
2786
+ hubspot: "https://developers.hubspot.com/docs/reference/api/crm/objects/contacts",
2787
+ // lib/sendgrid.js posts to /v3/mail/send.
2788
+ sendgrid: "https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send",
2789
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
2790
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
2791
+ twilio: "https://www.twilio.com/docs/messaging/api/message-resource"
2792
+ },
2793
+ dashboard: {
2794
+ hubspot: "https://knowledge.hubspot.com/contacts/create-contacts",
2795
+ sendgrid: "https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed",
2796
+ twilio: "https://www.twilio.com/docs/messaging/guides/debugging-tools"
2797
+ },
2798
+ // An admin types these keys in; there is no merchant consent and no scope
2799
+ // model on any of the three.
2800
+ scopes: false,
2801
+ content: "2026-09-11",
2802
+ verified: null
2803
+ },
2565
2804
  slug: "drawbridge",
2566
2805
  // Always on. There is no credential that could go bad and no configuration a
2567
2806
  // merchant could leave half-finished.
@@ -2753,6 +2992,8 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
2753
2992
  }
2754
2993
  return response.status === 204 ? null : response.json();
2755
2994
  };
2995
+ var segmentName = (title) => "Drawbridge: " + title;
2996
+ var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
2756
2997
  var klaviyo_default2 = {
2757
2998
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
2758
2999
  // exchange without a code_verifier matching the challenge the consent
@@ -2782,9 +3023,21 @@ var klaviyo_default2 = {
2782
3023
  // exchange, and a copy here would be a second answer that goes stale.
2783
3024
  expiry: 90 * 24 * 60 * 60,
2784
3025
  pkce: true,
3026
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
3027
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
3028
+ // and set them using a space-separated list"
3029
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
3030
+ // 2026-09-11) — and a merchant's token only ever carries what they
3031
+ // consented to, so a scope added later is a reconnect for every one of
3032
+ // them. That is what segments cost when they were left out here.
3033
+ //
2785
3034
  // Space separated. accounts:read is required by Klaviyo on every app
2786
- // and must stay in the list; the rest are what a contact sync needs.
2787
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
3035
+ // and must stay in the list; the rest are what a contact sync and the
3036
+ // segment hooks need — Get Segments lists `segments:read`, Create,
3037
+ // Update and Delete Segment each list `segments:write`
3038
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3039
+ // revision 2026-07-15, fetched 2026-09-11).
3040
+ scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
2788
3041
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
2789
3042
  // the disconnect hook — three vendor addresses, two of them declared,
2790
3043
  // which is exactly the kind of split that goes unnoticed.
@@ -2833,9 +3086,10 @@ var klaviyo_default2 = {
2833
3086
  // Shown at disconnect, so it says what is lost and what is not.
2834
3087
  confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
2835
3088
  description: [
2836
- "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.",
3089
+ "Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.",
2837
3090
  "You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.",
2838
- "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing."
3091
+ "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
3092
+ "Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them."
2839
3093
  ],
2840
3094
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
2841
3095
  // likely to grow — resources.* has already earned somewhere to put "we
@@ -3030,7 +3284,14 @@ var klaviyo_default2 = {
3030
3284
  // `segments` is null when the run carried no contact document,
3031
3285
  // meaning nobody looked — different from [], which means they
3032
3286
  // are in none. Null omits the key and merge leaves it alone.
3033
- ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
3287
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) },
3288
+ // THE IDS, which is what a Drawbridge-made segment's definition
3289
+ // filters on. Ids rather than titles, so renaming a segment is a
3290
+ // name change at Klaviyo and not a resync of every profile.
3291
+ //
3292
+ // The titles stay beside them: merchants have been building
3293
+ // their own segments on that array since it shipped.
3294
+ ...segments && { drawbridge_segment_ids: segments.map((entry) => entry.id).filter(Boolean) }
3034
3295
  }
3035
3296
  },
3036
3297
  type: "profile"
@@ -3074,17 +3335,141 @@ var klaviyo_default2 = {
3074
3335
  };
3075
3336
  }
3076
3337
  },
3077
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3078
- // register nothing with it, so listing four falses would be noise around a
3079
- // single decision. Still explicit absence would not say whether anybody
3080
- // considered it.
3081
- // Drawbridge sends its own notification email and SMS, and owns its own
3082
- // segments — see the private `drawbridge` manifest. A vendor answering
3083
- // these would be a second sender, which is the arrangement the platform
3084
- // sender replaced.
3338
+ // Drawbridge sends its own notification email. A vendor answering this
3339
+ // would be a second sender, which is the arrangement the platform sender
3340
+ // replaced. Declined as one line rather than one per verb, because the whole
3341
+ // domain is one decision — still explicit, since absence would not say
3342
+ // whether anybody considered it.
3085
3343
  email: false,
3086
- segment: false,
3344
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3345
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3346
+ // below — while register and remove keep a Klaviyo segment standing for
3347
+ // each Drawbridge segment, so the merchant can target one in their own
3348
+ // flows.
3349
+ segment: {
3350
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3351
+ //
3352
+ // Klaviyo owns no writable membership — its segments are computed from
3353
+ // rules — so the segment we create is DEFINED BY the profile property
3354
+ // contacts.sync writes. The definition filters on the Drawbridge
3355
+ // segment's ID, never its title, which is what makes a rename one PATCH
3356
+ // instead of a resync of every profile in it.
3357
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
3358
+ var _a, _b, _c, _d, _e;
3359
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
3360
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
3361
+ if (!canManageSegments(settings)) {
3362
+ return {
3363
+ message: "Reconnect Klaviyo to let Drawbridge manage segments \u2014 this connection was made before that permission was asked for.",
3364
+ skipped: true
3365
+ };
3366
+ }
3367
+ const name = segmentName(segment.title);
3368
+ const existing = segmentRowFor({ connection: connection2, segment });
3369
+ let id = null;
3370
+ if (existing == null ? void 0 : existing.id) {
3371
+ try {
3372
+ const found = await api2("/segments/" + existing.id, { fetcher, token });
3373
+ id = ((_a = found == null ? void 0 : found.data) == null ? void 0 : _a.id) ?? existing.id;
3374
+ if (((_c = (_b = found == null ? void 0 : found.data) == null ? void 0 : _b.attributes) == null ? void 0 : _c.name) !== name) {
3375
+ await api2("/segments/" + existing.id, {
3376
+ fetcher,
3377
+ method: "PATCH",
3378
+ payload: { data: { attributes: { name }, id: existing.id, type: "segment" } },
3379
+ token
3380
+ });
3381
+ }
3382
+ } catch (error) {
3383
+ if (error.status !== 404) throw error;
3384
+ id = null;
3385
+ }
3386
+ }
3387
+ if (!id) {
3388
+ const search = await api2("/segments?filter=" + encodeURIComponent('equals(name,"' + name.replace(/"/g, '\\"') + '")'), { fetcher, token });
3389
+ id = ((_d = ((search == null ? void 0 : search.data) || []).find((entry) => {
3390
+ var _a2;
3391
+ return ((_a2 = entry == null ? void 0 : entry.attributes) == null ? void 0 : _a2.name) === name;
3392
+ })) == null ? void 0 : _d.id) ?? null;
3393
+ }
3394
+ if (!id) {
3395
+ const created = await api2("/segments", {
3396
+ fetcher,
3397
+ method: "POST",
3398
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
3399
+ // — `name` and `definition` are both required on its attributes
3400
+ // — and a custom profile property is addressed as
3401
+ // "properties['property name']", tested with a list filter whose
3402
+ // operator is `contains`
3403
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3404
+ // revision 2026-07-15, fetched 2026-09-11).
3405
+ payload: {
3406
+ data: {
3407
+ attributes: {
3408
+ definition: {
3409
+ condition_groups: [{
3410
+ conditions: [{
3411
+ filter: { operator: "contains", type: "list", value: segment.id },
3412
+ property: "properties['drawbridge_segment_ids']",
3413
+ type: "profile-property"
3414
+ }]
3415
+ }]
3416
+ },
3417
+ name
3418
+ },
3419
+ type: "segment"
3420
+ }
3421
+ },
3422
+ token
3423
+ });
3424
+ id = (_e = created == null ? void 0 : created.data) == null ? void 0 : _e.id;
3425
+ }
3426
+ if (!id) return { message: "Klaviyo returned no segment id.", skipped: true };
3427
+ return {
3428
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
3429
+ // coalescing job id, so the last thing this does is look again.
3430
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
3431
+ events: [{
3432
+ event: "organization.segments",
3433
+ payload: { id: segment.id },
3434
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
3435
+ }],
3436
+ message: 'Klaviyo is carrying this segment as "' + name + '".',
3437
+ writes: segmentRowWrites({
3438
+ connection: connection2,
3439
+ data: { ...connection2, settings },
3440
+ manifest,
3441
+ row: { id, type: "segment" },
3442
+ segment
3443
+ })
3444
+ };
3445
+ },
3446
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
3447
+ // copy, and it carries the row naming what to delete.
3448
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
3449
+ const segment = context == null ? void 0 : context.segment;
3450
+ const existing = segmentRowFor({ connection: connection2, segment });
3451
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Klaviyo was never carrying this segment.", skipped: true };
3452
+ if (!canManageSegments(settings)) {
3453
+ return { message: "Reconnect Klaviyo to let Drawbridge manage segments.", skipped: true };
3454
+ }
3455
+ try {
3456
+ await api2("/segments/" + existing.id, { fetcher, method: "DELETE", token });
3457
+ } catch (error) {
3458
+ if (error.status !== 404) throw error;
3459
+ }
3460
+ return {
3461
+ message: "Klaviyo is no longer carrying this segment.",
3462
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
3463
+ };
3464
+ },
3465
+ // Drawbridge-side membership belongs to the private manifest.
3466
+ sync: false
3467
+ },
3468
+ // Declined for the same reason as `email` above: Drawbridge sends its own
3469
+ // notification SMS, and a vendor answering this would be a second sender.
3087
3470
  sms: false,
3471
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
3472
+ // to verify.
3088
3473
  inbound: false,
3089
3474
  // Nothing to set up or tear down at the vendor: the grant is the whole
3090
3475
  // integration. What CAN rot is the grant itself, so health is the one
@@ -3205,6 +3590,18 @@ var klaviyo_default2 = {
3205
3590
  "KLAVIYO_OAUTH_CLIENT_ID",
3206
3591
  "KLAVIYO_OAUTH_CLIENT_SECRET"
3207
3592
  ],
3593
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
3594
+ review: {
3595
+ api: "https://developers.klaviyo.com/en/reference/api_overview",
3596
+ dashboard: "https://help.klaviyo.com/hc/en-us/articles/115005078647",
3597
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
3598
+ // example scope string and nothing to check a manifest against; this page
3599
+ // lists the scopes each API takes, segments:read and segments:write among
3600
+ // them (fetched 2026-09-11).
3601
+ scopes: "https://developers.klaviyo.com/en/docs/authenticate_",
3602
+ content: "2026-09-11",
3603
+ verified: null
3604
+ },
3208
3605
  slug: "klaviyo",
3209
3606
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3210
3607
  // is already the merchant-facing copy channel and is already rendered.
@@ -3235,7 +3632,8 @@ var klaviyo_default2 = {
3235
3632
  hook: "lifecycle.health",
3236
3633
  key: "Klaviyo Connection Health",
3237
3634
  queue: "connection",
3238
- system: true
3635
+ system: true,
3636
+ trigger: { event: "day", type: "schedule" }
3239
3637
  })
3240
3638
  }
3241
3639
  },
@@ -3279,6 +3677,28 @@ var klaviyo_default2 = {
3279
3677
  usage: { actions: 1 }
3280
3678
  };
3281
3679
  }
3680
+ },
3681
+ segment: {
3682
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
3683
+ // these fire from the segment's own lifecycle, not from a workflow
3684
+ // somebody assembled. The trigger is declared here rather than hard-coded
3685
+ // in drawbridge-sync.
3686
+ register: () => ({
3687
+ description: "Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.",
3688
+ hook: "segment.register",
3689
+ key: "Klaviyo Segment Register",
3690
+ queue: "connection",
3691
+ system: true,
3692
+ trigger: { event: "segment.register", type: "event" }
3693
+ }),
3694
+ remove: () => ({
3695
+ description: "Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.",
3696
+ hook: "segment.remove",
3697
+ key: "Klaviyo Segment Remove",
3698
+ queue: "connection",
3699
+ system: true,
3700
+ trigger: { event: "segment.remove", type: "event" }
3701
+ })
3282
3702
  }
3283
3703
  },
3284
3704
  // WHY, in the merchant's words, and what to do about it.
@@ -3288,18 +3708,36 @@ var klaviyo_default2 = {
3288
3708
  // moment: the grant is good and the list is the missing half.
3289
3709
  tasks: (data2) => {
3290
3710
  var _a;
3291
- return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
3292
- {
3711
+ if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3712
+ return [
3713
+ // A connection made before segments were requested is authenticated and
3714
+ // cannot manage them, and no error surfaces anywhere else — the register
3715
+ // runs skip rather than fail.
3716
+ ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3717
+ message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3718
+ title: "Reconnect Klaviyo"
3719
+ }],
3720
+ ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3293
3721
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3294
3722
  title: "Choose a list"
3295
- }
3723
+ }]
3296
3724
  ];
3297
3725
  },
3298
- title: "Klaviyo"
3726
+ title: "Klaviyo",
3727
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
3728
+ // is its own help centre on a list: "you can find a list's ID in the URL in
3729
+ // your browser when viewing this list"
3730
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
3731
+ // segment's page is the sibling form of it. The path itself is NOT published
3732
+ // anywhere citable, so the dev walk-through confirms this against a real
3733
+ // account before promote.
3734
+ urls: {
3735
+ segment: (row2) => (row2 == null ? void 0 : row2.id) ? "https://www.klaviyo.com/segment/" + row2.id : null
3736
+ }
3299
3737
  };
3300
3738
 
3301
3739
  // lib/connections/providers/mailchimp.js
3302
- var import_node_crypto4 = require("crypto");
3740
+ var import_node_crypto5 = require("crypto");
3303
3741
 
3304
3742
  // lib/connections/icons/mailchimp.js
3305
3743
  var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -3331,7 +3769,8 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3331
3769
  }
3332
3770
  return response.status === 204 ? null : response.json();
3333
3771
  };
3334
- var subscriberHash = (email) => (0, import_node_crypto4.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3772
+ var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3773
+ var tagName = (title) => "Drawbridge: " + title;
3335
3774
  var mailchimp_default2 = {
3336
3775
  // OAUTH 2, authorization code. Every url below is quoted from
3337
3776
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3374,7 +3813,7 @@ var mailchimp_default2 = {
3374
3813
  confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
3375
3814
  description: [
3376
3815
  "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.",
3377
- "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.",
3816
+ "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.",
3378
3817
  "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.",
3379
3818
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3380
3819
  ],
@@ -3387,6 +3826,7 @@ var mailchimp_default2 = {
3387
3826
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3388
3827
  "You come back here to pick the audience your contacts should sync into.",
3389
3828
  "The connection shows Pending until you pick an audience, then Active.",
3829
+ '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.',
3390
3830
  "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3391
3831
  ]
3392
3832
  },
@@ -3412,10 +3852,9 @@ var mailchimp_default2 = {
3412
3852
  }
3413
3853
  ],
3414
3854
  group: "contacts",
3415
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
3416
- // else is built yet, because audience sync has not shipped. Every false here
3417
- // is "not yet" rather than "never" when the sync lands, probe and
3418
- // contacts.sync are the first to flip.
3855
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
3856
+ // a paragraph up here that goes stale the moment one of them is implemented
3857
+ // which is exactly what happened to the note this replaces.
3419
3858
  hooks: {
3420
3859
  auth: {
3421
3860
  // WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
@@ -3469,26 +3908,32 @@ var mailchimp_default2 = {
3469
3908
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3470
3909
  // Marketing API reference for the list-members resource.
3471
3910
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3472
- var _a, _b;
3911
+ var _a, _b, _c;
3473
3912
  const audience = settings == null ? void 0 : settings.audience;
3474
3913
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3475
3914
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
3476
3915
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3477
3916
  const hash = subscriberHash(email);
3917
+ const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
3918
+ const lastName = restOfName.join(" ");
3919
+ const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
3920
+ const mergeFields = {
3921
+ ...firstName && { FNAME: firstName },
3922
+ ...lastName && { LNAME: lastName },
3923
+ ...phone && { PHONE: phone }
3924
+ };
3478
3925
  const member = await api3("/lists/" + audience + "/members/" + hash, {
3479
3926
  dc: settings == null ? void 0 : settings.dc,
3480
3927
  fetcher,
3481
3928
  method: "PUT",
3482
3929
  payload: {
3483
3930
  email_address: email,
3484
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
3485
- // schemaless a merge tag that does not exist on the audience is
3486
- // refused, taking the whole request with it and FNAME is one of
3487
- // the two tags every audience is created with. The Drawbridge
3488
- // totals Klaviyo receives cannot travel until something registers
3489
- // merge fields on the chosen audience, which is lifecycle.register's
3490
- // job and is not built.
3491
- ...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
3931
+ // Built above. Omitted entirely when there is nothing to say, so a
3932
+ // lead with only an address does not send an empty object. The
3933
+ // Drawbridge totals Klaviyo receives still cannot travel this way
3934
+ // those are custom tags, and registering them on the chosen audience
3935
+ // is lifecycle.register's job and is not built.
3936
+ ...Object.keys(mergeFields).length > 0 && { merge_fields: mergeFields },
3492
3937
  ...suppressed && { status: "unsubscribed" },
3493
3938
  status_if_new: suppressed ? "unsubscribed" : "subscribed"
3494
3939
  },
@@ -3511,7 +3956,7 @@ var mailchimp_default2 = {
3511
3956
  });
3512
3957
  const joined = new Set(segments.map((entry) => entry.title));
3513
3958
  const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3514
- name: "Drawbridge: " + title,
3959
+ name: tagName(title),
3515
3960
  status: joined.has(title) ? "active" : "inactive"
3516
3961
  }));
3517
3962
  if (tags.length > 0) {
@@ -3534,12 +3979,120 @@ var mailchimp_default2 = {
3534
3979
  };
3535
3980
  }
3536
3981
  },
3537
- // Drawbridge sends its own notification email and SMS, and owns its own
3538
- // segments see the private `drawbridge` manifest. A vendor answering
3539
- // these would be a second sender, which is the arrangement the platform
3540
- // 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.
3541
3985
  email: false,
3542
- segment: false,
3986
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3987
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3988
+ // below — while register and remove keep a Mailchimp tag standing for each
3989
+ // Drawbridge segment, so the merchant can target one in their own audience.
3990
+ segment: {
3991
+ // THE TAG THIS SEGMENT IS, held by id at last.
3992
+ //
3993
+ // Tags ARE static segments in Mailchimp's model — same collection, same
3994
+ // ids — so this creates one through /segments and the member write goes
3995
+ // on attaching people to it by name. Both address the same object. The
3996
+ // segment schema says it outright: "The type of segment. Static segments
3997
+ // are now known as tags"
3998
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
3999
+ //
4000
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4001
+ // sweep and on backfill, it converges. That is what lets one hook serve
4002
+ // all four without a create-vs-update branch anywhere else.
4003
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
4004
+ var _a;
4005
+ const audience = settings == null ? void 0 : settings.audience;
4006
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4007
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4008
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4009
+ const name = tagName(segment.title);
4010
+ const existing = segmentRowFor({ connection: connection2, segment });
4011
+ let id = null;
4012
+ if (existing == null ? void 0 : existing.id) {
4013
+ try {
4014
+ const found = await api3("/lists/" + audience + "/segments/" + existing.id, { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4015
+ id = (found == null ? void 0 : found.id) ?? existing.id;
4016
+ if ((found == null ? void 0 : found.name) !== name) {
4017
+ await api3("/lists/" + audience + "/segments/" + existing.id, {
4018
+ dc: settings == null ? void 0 : settings.dc,
4019
+ fetcher,
4020
+ method: "PATCH",
4021
+ payload: { name },
4022
+ token
4023
+ });
4024
+ }
4025
+ } catch (error) {
4026
+ if (error.status !== 404) throw error;
4027
+ id = null;
4028
+ }
4029
+ }
4030
+ if (!id) {
4031
+ const search = await api3("/lists/" + audience + "/tag-search?name=" + encodeURIComponent(name), { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4032
+ id = ((_a = ((search == null ? void 0 : search.tags) || []).find((tag) => (tag == null ? void 0 : tag.name) === name)) == null ? void 0 : _a.id) ?? null;
4033
+ }
4034
+ if (!id) {
4035
+ const created = await api3("/lists/" + audience + "/segments", {
4036
+ dc: settings == null ? void 0 : settings.dc,
4037
+ fetcher,
4038
+ method: "POST",
4039
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
4040
+ // name; this call only has to make the object exist. Mailchimp's
4041
+ // own wording for the empty array: "Passing an empty array will
4042
+ // create a static segment without any subscribers."
4043
+ payload: { name, static_segment: [] },
4044
+ token
4045
+ });
4046
+ id = created == null ? void 0 : created.id;
4047
+ }
4048
+ if (!id) return { message: "Mailchimp returned no tag id.", skipped: true };
4049
+ const audienceDetail = await api3("/lists/" + audience + "?fields=web_id", { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4050
+ return {
4051
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4052
+ // coalescing job id, so the last thing this does is look again.
4053
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
4054
+ events: [{
4055
+ event: "organization.segments",
4056
+ payload: { id: segment.id },
4057
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
4058
+ }],
4059
+ message: 'Mailchimp is carrying this segment as the tag "' + name + '".',
4060
+ writes: segmentRowWrites({
4061
+ connection: connection2,
4062
+ data: { ...connection2, settings },
4063
+ manifest,
4064
+ row: { id, type: "tag", webId: audienceDetail == null ? void 0 : audienceDetail.web_id },
4065
+ segment
4066
+ })
4067
+ };
4068
+ },
4069
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
4070
+ // whole pair exists to stop — every member would keep a label for a
4071
+ // segment that no longer exists.
4072
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
4073
+ const segment = context == null ? void 0 : context.segment;
4074
+ const existing = segmentRowFor({ connection: connection2, segment });
4075
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Mailchimp was never carrying this segment.", skipped: true };
4076
+ try {
4077
+ await api3("/lists/" + (settings == null ? void 0 : settings.audience) + "/segments/" + existing.id, {
4078
+ dc: settings == null ? void 0 : settings.dc,
4079
+ fetcher,
4080
+ method: "DELETE",
4081
+ token
4082
+ });
4083
+ } catch (error) {
4084
+ if (error.status !== 404) throw error;
4085
+ }
4086
+ return {
4087
+ message: "Mailchimp is no longer carrying this segment.",
4088
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
4089
+ };
4090
+ },
4091
+ // Drawbridge-side membership belongs to the private manifest.
4092
+ sync: false
4093
+ },
4094
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4095
+ // notification SMS, and a vendor answering this would be a second sender.
3543
4096
  sms: false,
3544
4097
  inbound: false,
3545
4098
  lifecycle: false,
@@ -3602,6 +4155,16 @@ var mailchimp_default2 = {
3602
4155
  "MAILCHIMP_OAUTH_CLIENT_ID",
3603
4156
  "MAILCHIMP_OAUTH_CLIENT_SECRET"
3604
4157
  ],
4158
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4159
+ review: {
4160
+ api: "https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/",
4161
+ dashboard: "https://mailchimp.com/help/manage-tags/",
4162
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
4163
+ // account-wide — so there is nothing to request and nothing to re-consent.
4164
+ scopes: false,
4165
+ content: "2026-09-11",
4166
+ verified: null
4167
+ },
3605
4168
  slug: "mailchimp",
3606
4169
  // A grant with no audience chosen is authenticated and useless — the sync has
3607
4170
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -3647,6 +4210,30 @@ var mailchimp_default2 = {
3647
4210
  // adds this step, and what is charged when it runs.
3648
4211
  usage: { actions: 1 }
3649
4212
  })
4213
+ },
4214
+ segment: {
4215
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4216
+ // these fire from the segment's own lifecycle, not from a workflow
4217
+ // somebody assembled.
4218
+ //
4219
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
4220
+ // which is what lets a vendor arrive with its own without a queue edit.
4221
+ register: () => ({
4222
+ description: "Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.",
4223
+ hook: "segment.register",
4224
+ key: "Mailchimp Segment Register",
4225
+ queue: "connection",
4226
+ system: true,
4227
+ trigger: { event: "segment.register", type: "event" }
4228
+ }),
4229
+ remove: () => ({
4230
+ description: "Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.",
4231
+ hook: "segment.remove",
4232
+ key: "Mailchimp Segment Remove",
4233
+ queue: "connection",
4234
+ system: true,
4235
+ trigger: { event: "segment.remove", type: "event" }
4236
+ })
3650
4237
  }
3651
4238
  },
3652
4239
  // WHY, in the merchant's words, and what to do about it.
@@ -3663,11 +4250,27 @@ var mailchimp_default2 = {
3663
4250
  }
3664
4251
  ];
3665
4252
  },
3666
- title: "Mailchimp"
4253
+ title: "Mailchimp",
4254
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
4255
+ // the web_id field is "The ID used in the Mailchimp web application. View this
4256
+ // list in your Mailchimp account at
4257
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
4258
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
4259
+ // 2026-09-11).
4260
+ //
4261
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
4262
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
4263
+ // click short rather than guessing at one that could break silently.
4264
+ urls: {
4265
+ segment: (row2, data2) => {
4266
+ var _a;
4267
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.dc) && (row2 == null ? void 0 : row2.webId) ? "https://" + data2.settings.dc + ".admin.mailchimp.com/lists/members/?id=" + row2.webId : null;
4268
+ }
4269
+ }
3667
4270
  };
3668
4271
 
3669
4272
  // lib/connections/providers/shopify.js
3670
- var import_node_crypto5 = require("crypto");
4273
+ var import_node_crypto6 = require("crypto");
3671
4274
  var import_nanoid3 = require("nanoid");
3672
4275
 
3673
4276
  // lib/connections/icons/shopify.js
@@ -3783,6 +4386,28 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3783
4386
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3784
4387
  );
3785
4388
  var generateDiscountCode = (0, import_nanoid3.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4389
+ var blockedReason = (discount) => {
4390
+ var _a;
4391
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4392
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4393
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4394
+ 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.";
4395
+ }
4396
+ ;
4397
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4398
+ return "This discount has reached its total usage limit.";
4399
+ }
4400
+ ;
4401
+ return null;
4402
+ };
4403
+ var discountWarning = (discount) => {
4404
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4405
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
4406
+ }
4407
+ ;
4408
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4409
+ return null;
4410
+ };
3786
4411
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3787
4412
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3788
4413
  var OAUTH_ERROR_SOURCE = "oauth";
@@ -3830,7 +4455,7 @@ var shopify_default2 = {
3830
4455
  description: [
3831
4456
  "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.",
3832
4457
  "Drawbridge attributes orders that originate from your campaigns \u2014 matched through cart parameters and lead-mapped discount codes \u2014 so you can see the revenue each campaign drives.",
3833
- "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."
4458
+ "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."
3834
4459
  ],
3835
4460
  errors: {
3836
4461
  connect: {
@@ -3844,7 +4469,7 @@ var shopify_default2 = {
3844
4469
  "Open the Drawbridge listing on the Shopify App Store.",
3845
4470
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3846
4471
  "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3847
- "Come back here \u2014 the connections list updates on its own once the install lands."
4472
+ "Come back here \u2014 the connections list updates on its own once the install finishes."
3848
4473
  ],
3849
4474
  // Names where the link GOES rather than what it does: installing happens on
3850
4475
  // the App Store listing, and the dashboard must never imply a store can be
@@ -4220,9 +4845,11 @@ var shopify_default2 = {
4220
4845
  phone: customerPhone
4221
4846
  } : null;
4222
4847
  const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
4223
- const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
4848
+ const createsOrder = !backfill && (isConversion || Boolean(discount));
4849
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (createsOrder ? mintId() : null);
4850
+ const redemptionDocId = discount ? mintId() : null;
4224
4851
  const writes = [];
4225
- if (isConversion && !backfill) {
4852
+ if (createsOrder) {
4226
4853
  writes.push({
4227
4854
  collection: "order",
4228
4855
  data: {
@@ -4243,15 +4870,25 @@ var shopify_default2 = {
4243
4870
  provider: { id: String(orderId), slug: "shopify" },
4244
4871
  purchasedAt,
4245
4872
  rate,
4873
+ // Null on a conversion that matched no code of ours; the
4874
+ // backfill branch below sets it when one arrives later.
4875
+ redemption: redemptionDocId,
4246
4876
  source,
4247
- status: "completed"
4877
+ status: "completed",
4878
+ type: isConversion ? "conversion" : "redemption"
4248
4879
  },
4249
4880
  operation: "create"
4250
4881
  });
4251
4882
  if (org == null ? void 0 : org.usage) {
4252
4883
  writes.push({
4253
4884
  collection: "usage",
4254
- data: { $inc: { "totals.revenue": gross } },
4885
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
4886
+ // conversion revenue and is the figure the fee is charged
4887
+ // against, so redemption money gets its own key rather than
4888
+ // changing what an existing number means.
4889
+ data: {
4890
+ $inc: isConversion ? { "totals.revenue": gross } : { "totals.redemptionRevenue": gross }
4891
+ },
4255
4892
  operation: "update",
4256
4893
  query: { id: org.usage }
4257
4894
  });
@@ -4259,7 +4896,12 @@ var shopify_default2 = {
4259
4896
  if (leadId) {
4260
4897
  writes.push({
4261
4898
  collection: "lead",
4262
- data: { $inc: { "totals.orders": 1 } },
4899
+ // Same grouped shape the contact carries, so a lead and the
4900
+ // contact built from it cannot be read two different ways.
4901
+ data: { $inc: {
4902
+ "totals.orders.total": 1,
4903
+ ...isConversion ? { "totals.orders.conversions": 1 } : { "totals.orders.redemptions": 1 }
4904
+ } },
4263
4905
  operation: "update",
4264
4906
  options: { bypassDocumentValidation: true },
4265
4907
  query: { id: leadId }
@@ -4278,6 +4920,7 @@ var shopify_default2 = {
4278
4920
  customer,
4279
4921
  discount,
4280
4922
  gross,
4923
+ id: redemptionDocId,
4281
4924
  lead: leadId,
4282
4925
  order: orderDocId,
4283
4926
  organization: campaignOrganization,
@@ -4306,6 +4949,14 @@ var shopify_default2 = {
4306
4949
  query: { id: leadId }
4307
4950
  });
4308
4951
  }
4952
+ if (backfill && orderDocId && redemptionDocId) {
4953
+ writes.push({
4954
+ collection: "order",
4955
+ data: { $set: { redemption: redemptionDocId } },
4956
+ operation: "update",
4957
+ query: { id: orderDocId }
4958
+ });
4959
+ }
4309
4960
  }
4310
4961
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4311
4962
  const providerRow2 = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
@@ -4684,7 +5335,7 @@ var shopify_default2 = {
4684
5335
  event: "shopify.register.webhooks"
4685
5336
  },
4686
5337
  name: "register",
4687
- options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
5338
+ options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
4688
5339
  queue: "connection"
4689
5340
  }],
4690
5341
  message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
@@ -4798,10 +5449,19 @@ var shopify_default2 = {
4798
5449
  // picker stores, which is why the tail is taken here rather than by
4799
5450
  // each caller that happened to remember.
4800
5451
  items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
4801
- var _a2, _b2, _c;
5452
+ var _a2, _b2;
5453
+ const node = ((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.codeDiscount) || {};
4802
5454
  return {
4803
- id: String(((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id) || "").split("/").pop(),
4804
- title: (_c = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.codeDiscount) == null ? void 0 : _c.title
5455
+ // Null when the discount can be used, a sentence when it cannot.
5456
+ // The picker greys the row and shows this instead of hiding it:
5457
+ // a discount the merchant can see in Shopify admin, missing here
5458
+ // with no explanation, reads as a bug in us.
5459
+ blocked: blockedReason(node),
5460
+ id: String(((_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.id) || "").split("/").pop(),
5461
+ // Usable, but not in the way the merchant probably expects.
5462
+ // Shown beside the row without stopping them.
5463
+ warning: discountWarning(node),
5464
+ title: node.title
4805
5465
  };
4806
5466
  }),
4807
5467
  pageInfo: {
@@ -4816,20 +5476,6 @@ var shopify_default2 = {
4816
5476
  },
4817
5477
  icon: shopify_default,
4818
5478
  inbound,
4819
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
4820
- //
4821
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
4822
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
4823
- // through, which is the arrangement these manifests exist to remove.
4824
- //
4825
- // Undefined until a shop is linked, so the Manage button only appears on a
4826
- // connected connection. The app handle is NAMED by `requires` and read from
4827
- // the env the resolver passes, never from process.env here.
4828
- manage: (data2, env) => {
4829
- var _a;
4830
- const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
4831
- return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
4832
- },
4833
5479
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
4834
5480
  // what an admin types on the provider screen. The four names below are exactly
4835
5481
  // what `requires` gates on, which is the point of declaring them together: a
@@ -4875,6 +5521,14 @@ var shopify_default2 = {
4875
5521
  "SHOPIFY_APP_LISTING_URL",
4876
5522
  "SHOPIFY_APP_HANDLE"
4877
5523
  ],
5524
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5525
+ review: {
5526
+ api: "https://shopify.dev/docs/api/admin-graphql",
5527
+ dashboard: "https://help.shopify.com/en/manual/apps",
5528
+ scopes: "https://shopify.dev/docs/api/usage/access-scopes",
5529
+ content: "2026-09-11",
5530
+ verified: null
5531
+ },
4878
5532
  slug: "shopify",
4879
5533
  // The install is the whole configuration — Shopify hands back the shop and
4880
5534
  // there is nothing further to choose. `shop` absent means the install did not
@@ -4962,8 +5616,11 @@ var shopify_default2 = {
4962
5616
  })
4963
5617
  },
4964
5618
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
4965
- // in the builder, so they carry no trigger and no usage. Declared because
4966
- // the routing table and the system-workflow descriptions both read here.
5619
+ // in the builder, so they carry no usage. These two are fired by a webhook
5620
+ // arriving rather than by a workflow trigger, so they name none either —
5621
+ // and naming none is what stops a workflow being provisioned for them.
5622
+ // Declared because the routing table and the system-workflow descriptions
5623
+ // both read here.
4967
5624
  order: {
4968
5625
  record: () => ({
4969
5626
  description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
@@ -4995,7 +5652,8 @@ var shopify_default2 = {
4995
5652
  hook: "lifecycle.health",
4996
5653
  key: "Shopify Connection Health",
4997
5654
  queue: "connection",
4998
- system: true
5655
+ system: true,
5656
+ trigger: { event: "day", type: "schedule" }
4999
5657
  })
5000
5658
  },
5001
5659
  // Audit-only. The "Shopify Token Activity" system workflow lists these
@@ -5056,11 +5714,34 @@ var shopify_default2 = {
5056
5714
  ] : []
5057
5715
  ];
5058
5716
  },
5059
- title: "Shopify"
5717
+ title: "Shopify",
5718
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
5719
+ // here rather than at the top level so a second link (a product, an order)
5720
+ // is a key in this object instead of a new manifest key nobody agreed on.
5721
+ //
5722
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
5723
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
5724
+ // vendor runs through, which is the arrangement these manifests exist to
5725
+ // remove.
5726
+ //
5727
+ // Never projected: the api composes connect.manage from it, and
5728
+ // resolveConnection drops the object, because a url built from settings is
5729
+ // built where the settings are already decrypted.
5730
+ urls: {
5731
+ // Undefined until a shop is linked, so the Manage button only appears on a
5732
+ // connected connection. The app handle is NAMED by `requires` and read from
5733
+ // the env its caller passes — the api's resolve() hands it the stored
5734
+ // credentials, never process.env.
5735
+ manage: (data2, env) => {
5736
+ var _a;
5737
+ const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
5738
+ return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
5739
+ }
5740
+ }
5060
5741
  };
5061
5742
 
5062
5743
  // lib/connections/providers/webhook.js
5063
- var import_node_crypto6 = __toESM(require("crypto"), 1);
5744
+ var import_node_crypto7 = __toESM(require("crypto"), 1);
5064
5745
 
5065
5746
  // lib/safe-http.js
5066
5747
  var import_dns2 = __toESM(require("dns"), 1);
@@ -5214,7 +5895,7 @@ var signature = ({ body, settings }) => {
5214
5895
  return [
5215
5896
  "t=" + timestamp,
5216
5897
  ...[settings.secret, ...previous].map(
5217
- (secret) => "v1=" + import_node_crypto6.default.createHmac("sha256", secret).update(payload).digest("hex")
5898
+ (secret) => "v1=" + import_node_crypto7.default.createHmac("sha256", secret).update(payload).digest("hex")
5218
5899
  )
5219
5900
  ].join(",");
5220
5901
  };
@@ -5236,7 +5917,7 @@ var webhook_default = {
5236
5917
  content: {
5237
5918
  confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
5238
5919
  description: [
5239
- "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
5920
+ "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
5240
5921
  "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."
5241
5922
  ],
5242
5923
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -5337,6 +6018,9 @@ var webhook_default = {
5337
6018
  // Gated on the encryption secret: without it the signing secret could not be
5338
6019
  // stored safely, so the connection must not be offered at all.
5339
6020
  requires: ["ENCRYPT_CONNECTION_SECRET"],
6021
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
6022
+ // to link to and no scope to request: connecting mints a secret.
6023
+ review: false,
5340
6024
  // Outbound only. inbound.* is false because the direction is the point: we
5341
6025
  // sign and POST to the merchant's endpoint, they never call us. Every other
5342
6026
  // false follows from there being no third party to authenticate against —
@@ -5643,7 +6327,7 @@ var mergeSettings = ({ existing, incoming }) => {
5643
6327
  var publicConnectionKeys = Object.freeze([
5644
6328
  "actions",
5645
6329
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
5646
- // auth.type, content.redirect and the manifest's manage() — the client reads
6330
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
5647
6331
  // connect.type to choose entered-vs-installed, connect.redirect for the App
5648
6332
  // Store link, connect.manage for the admin deep link. It was dropped from
5649
6333
  // this list when the manifests stopped declaring it, which stripped the
@@ -5702,20 +6386,20 @@ var clearProviderMemo = () => providerMemo.clear();
5702
6386
  var providerRow = async ({ controller, slug: slug2 }) => {
5703
6387
  const memoized = providerMemo.get(slug2);
5704
6388
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
5705
- const row = await controller.get({
6389
+ const row2 = await controller.get({
5706
6390
  collection: "provider",
5707
6391
  query: { slug: slug2 }
5708
6392
  });
5709
6393
  const value = {
5710
- enabled: (row == null ? void 0 : row.enabled) !== false,
5711
- settings: (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {}
6394
+ enabled: (row2 == null ? void 0 : row2.enabled) !== false,
6395
+ settings: (row2 == null ? void 0 : row2.settings) ? decrypt(row2.settings) : {}
5712
6396
  };
5713
6397
  providerMemo.set(slug2, { at: Date.now(), value });
5714
6398
  return value;
5715
6399
  };
5716
6400
  var providerSettings = async ({ controller, includeDisabled = false, slug: slug2 }) => {
5717
- const row = await providerRow({ controller, slug: slug2 });
5718
- return row.enabled || includeDisabled ? row.settings : {};
6401
+ const row2 = await providerRow({ controller, slug: slug2 });
6402
+ return row2.enabled || includeDisabled ? row2.settings : {};
5719
6403
  };
5720
6404
  var vendorEnabled = async ({ controller, vendor }) => (await providerRow({ controller, slug: vendor })).enabled;
5721
6405
  var vendorSettings = async ({ controller, slug: slug2 }) => {