@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.
@@ -205,7 +205,20 @@ var HOOKS = Object.freeze({
205
205
  "digest"
206
206
  ]),
207
207
  sms: Object.freeze(["send"]),
208
- segment: Object.freeze(["sync"]),
208
+ segment: Object.freeze([
209
+ // Make this segment's object exist at the vendor, carrying the segment's
210
+ // current title, and describe the row that points at it. IDEMPOTENT: the
211
+ // same call creates it, renames it after an edit, and backfills a segment
212
+ // that predates the connection — so one hook serves every path and there
213
+ // is no create-vs-update branch to keep in step.
214
+ "register",
215
+ // Remove the vendor object this connection's row points at. Called with
216
+ // the pre-image on a segment delete, because by then the document is gone.
217
+ "remove",
218
+ // Recalculate Drawbridge-side membership. Private to the drawbridge
219
+ // manifest; a vendor does not own who is in a Drawbridge segment.
220
+ "sync"
221
+ ]),
209
222
  // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
210
223
  // The Webhooks connection is the only thing here with no third party behind
211
224
  // it, and the destination is per STEP rather than per connection.
@@ -350,6 +363,8 @@ var STEPS = Object.freeze({
350
363
  "email.digest": "Digest",
351
364
  "email.notify": "Notification",
352
365
  "email.send": "Send email",
366
+ "segment.register": "Register segment",
367
+ "segment.remove": "Remove segment",
353
368
  "segment.sync": "Sync segment",
354
369
  "sms.send": "Send SMS",
355
370
  "webhook.send": "Send webhook"
@@ -513,7 +528,16 @@ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
513
528
  ...tokens.expiresIn && {
514
529
  expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
515
530
  },
516
- ...tokens.scope && { scope: tokens.scope }
531
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
532
+ // authorization_code response and documents no response body at all for the
533
+ // refresh grant, so taking the minted value alone drops the stored one. That
534
+ // matters because `scope` is load-bearing: the segment hooks gate on
535
+ // `segments:write` and answer `skipped` when it is absent, so a connection
536
+ // that dropped it disables its whole segment half without failing anything
537
+ // and shows a reconnect task that reconnecting has already fixed.
538
+ ...(tokens.scope || existing.scope) && {
539
+ scope: tokens.scope || existing.scope
540
+ }
517
541
  });
518
542
  var accessToken = async ({
519
543
  clientId,
@@ -576,6 +600,98 @@ var detectCountry = (value) => {
576
600
  }
577
601
  };
578
602
 
603
+ // lib/connections/segment-rows.js
604
+ var import_node_crypto2 = require("crypto");
605
+ var row = ({ connection: connection2, data: data2, manifest, row: described }) => {
606
+ var _a, _b;
607
+ return {
608
+ connection: connection2 == null ? void 0 : connection2.id,
609
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
610
+ // strings, and one type in the schema is one comparison in the $or below.
611
+ id: String(described == null ? void 0 : described.id),
612
+ slug: connection2 == null ? void 0 : connection2.slug,
613
+ type: described == null ? void 0 : described.type,
614
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
615
+ // The url is built HERE, while the settings are decrypted and the vendor
616
+ // facts are in hand — an api reading the row later has neither.
617
+ 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
618
+ };
619
+ };
620
+ var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
621
+ const built = row({ connection: connection2, data: data2, manifest, row: described });
622
+ return [
623
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
624
+ // add nothing rather than a duplicate row for one connection.
625
+ {
626
+ collection: "segment",
627
+ data: { $push: { connections: built } },
628
+ operation: "update",
629
+ query: {
630
+ id: segment == null ? void 0 : segment.id,
631
+ "connections.connection": { $ne: connection2 == null ? void 0 : connection2.id }
632
+ }
633
+ },
634
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
635
+ // of its three mutable fields disagrees, so the steady state — the same
636
+ // vendor object, the same url — matches nothing and writes nothing.
637
+ {
638
+ collection: "segment",
639
+ data: { $set: { "connections.$": built } },
640
+ operation: "update",
641
+ query: {
642
+ id: segment == null ? void 0 : segment.id,
643
+ connections: {
644
+ $elemMatch: {
645
+ connection: connection2 == null ? void 0 : connection2.id,
646
+ $or: [
647
+ { id: { $ne: built.id } },
648
+ { type: { $ne: built.type } },
649
+ { url: { $ne: built.url } }
650
+ ]
651
+ }
652
+ }
653
+ }
654
+ }
655
+ ];
656
+ };
657
+ var segmentRowRemoveWrites = ({ connection: connection2, segment }) => [
658
+ {
659
+ collection: "segment",
660
+ data: { $pull: { connections: { connection: connection2 == null ? void 0 : connection2.id } } },
661
+ operation: "update",
662
+ query: { id: segment == null ? void 0 : segment.id }
663
+ }
664
+ ];
665
+ 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));
666
+ var currentSegment = async ({ read, segment }) => {
667
+ if (!(read == null ? void 0 : read.get) || !(segment == null ? void 0 : segment.id)) return segment;
668
+ return read.get({
669
+ collection: "segment",
670
+ query: { id: segment.id }
671
+ });
672
+ };
673
+ var driftEnqueues = async ({ applied, read, segment, workflow }) => {
674
+ if (!(workflow == null ? void 0 : workflow.id)) return [];
675
+ const fresh = await currentSegment({ read, segment });
676
+ if (!(fresh == null ? void 0 : fresh.id) || fresh.title === applied) return [];
677
+ return [{
678
+ data: {
679
+ triggerData: {
680
+ organization: fresh.organization || (workflow == null ? void 0 : workflow.organization),
681
+ segment: fresh
682
+ },
683
+ workflowId: workflow == null ? void 0 : workflow.id
684
+ },
685
+ name: "execute",
686
+ options: {
687
+ 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),
688
+ removeOnComplete: true,
689
+ removeOnFail: true
690
+ },
691
+ queue: "workflow"
692
+ }];
693
+ };
694
+
579
695
  // lib/connections/providers/attentive.js
580
696
  var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
581
697
  const response = await fetcher("https://api.attentivemobile.com" + path, {
@@ -628,7 +744,7 @@ var attentive_default2 = {
628
744
  // has to say so rather than let them believe otherwise.
629
745
  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.",
630
746
  description: [
631
- "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.",
747
+ "This connection syncs the contacts your campaigns collect into your Attentive account \u2014 subscribed for marketing, and added to the segment you choose.",
632
748
  "You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
633
749
  "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.",
634
750
  "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."
@@ -670,10 +786,9 @@ var attentive_default2 = {
670
786
  }
671
787
  ],
672
788
  group: "contacts",
673
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
674
- // nothing else is built yet, because subscriber sync has not shipped. Every
675
- // false here is "not yet" rather than "never" — when the sync lands, probe
676
- // and contacts.sync are the first to flip.
789
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
790
+ // a paragraph up here that goes stale the moment one of them is implemented
791
+ // which is exactly what happened to the note this replaces.
677
792
  hooks: {
678
793
  auth: {
679
794
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
@@ -686,7 +801,7 @@ var attentive_default2 = {
686
801
  // nobody re-derives it. Klaviyo's connect reads the account name back so
687
802
  // the card is not blank; Attentive's card stays blank. There IS an
688
803
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
689
- // on docs.attentive.com/pages/authentication/ as returning "information
804
+ // on docs.attentive.com/docs/authentication as returning "information
690
805
  // specific to your company" — but its RESPONSE SCHEMA is published
691
806
  // nowhere we can read: the docs show the curl and no body. Reading
692
807
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -853,7 +968,62 @@ var attentive_default2 = {
853
968
  products: false,
854
969
  promotions: false
855
970
  },
856
- segment: false,
971
+ segment: {
972
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
973
+ // one with an externalId we choose (docs.attentive.com/reference/
974
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
975
+ // required, `externalId` optional and "auto-generated if not supplied"),
976
+ // which would give a real per-segment object — but it takes
977
+ // segments:write, and scopes ride on the app registration, which does not
978
+ // exist yet.
979
+ //
980
+ // So the row points at the connection-level segment the merchant chose,
981
+ // `type` says so, and turning this into a per-segment object later is a
982
+ // change to this file and nothing else: create with
983
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
984
+ // update and archive endpoints are BOTH keyed by external id
985
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
986
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
987
+ // already hold addresses every one of the three calls.
988
+ //
989
+ // NO DRIFT CHECK, unlike the other two: the row points at the
990
+ // connection's own segment and the link is the index page, so nothing
991
+ // here depends on the Drawbridge segment's title — a rename has nothing
992
+ // to apply and nothing to race with. That comes back with the
993
+ // per-segment object.
994
+ //
995
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
996
+ // the simplest of the three registers and therefore the one the next
997
+ // vendor gets copied from — one job id serves four dispatch sites, so a
998
+ // copy that trusts context.segment applies whichever trigger data won
999
+ // the race, at a vendor where the title does matter.
1000
+ register: async ({ connection: connection2, context, manifest, settings }, { read } = {}) => {
1001
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
1002
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
1003
+ if (!(settings == null ? void 0 : settings.segment)) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
1004
+ return {
1005
+ events: [{
1006
+ event: "organization.segments",
1007
+ payload: { id: segment.id },
1008
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
1009
+ }],
1010
+ message: "Contacts in this segment are added to the Attentive segment chosen on this connection.",
1011
+ writes: segmentRowWrites({
1012
+ connection: connection2,
1013
+ data: { ...connection2, settings },
1014
+ manifest,
1015
+ row: { id: settings.segment, type: "segment" },
1016
+ segment
1017
+ })
1018
+ };
1019
+ },
1020
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
1021
+ // and it is where every Drawbridge segment's contacts go — removing it
1022
+ // because one Drawbridge segment was deleted would empty the others.
1023
+ remove: false,
1024
+ // Drawbridge-side membership belongs to the private manifest.
1025
+ sync: false
1026
+ },
857
1027
  sms: false,
858
1028
  webhook: false
859
1029
  },
@@ -882,6 +1052,21 @@ var attentive_default2 = {
882
1052
  "ATTENTIVE_OAUTH_CLIENT_ID",
883
1053
  "ATTENTIVE_OAUTH_CLIENT_SECRET"
884
1054
  ],
1055
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
1056
+ review: {
1057
+ api: "https://docs.attentive.com/reference/listsegments",
1058
+ dashboard: "https://docs.attentive.com/docs/segments",
1059
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
1060
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
1061
+ // privacy_requests:write — and says nothing about segments:read or
1062
+ // segments:write, which the segments API this manifest calls does take.
1063
+ // The header at the top of this file carries that distinction; it is
1064
+ // repeated here so a reviewer following the link is not misled by what the
1065
+ // table omits (fetched 2026-09-11).
1066
+ scopes: "https://docs.attentive.com/docs/authentication",
1067
+ content: "2026-09-11",
1068
+ verified: null
1069
+ },
885
1070
  slug: "attentive",
886
1071
  // A consent with no segment chosen is authenticated and inert — the sync needs
887
1072
  // somewhere to put people — so the card says Pending rather than Active over
@@ -919,6 +1104,24 @@ var attentive_default2 = {
919
1104
  triggers: ["lead.insert", "segment.contact.add"],
920
1105
  usage: { actions: 1 }
921
1106
  })
1107
+ },
1108
+ segment: {
1109
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
1110
+ // this fires from the segment's own lifecycle, not from a workflow
1111
+ // somebody assembled. The trigger is declared here rather than hard-coded
1112
+ // in drawbridge-sync.
1113
+ //
1114
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
1115
+ // declined, and build() refuses a step pointing at a hook this vendor does
1116
+ // not implement — so the two are one decision, enforced at import.
1117
+ register: () => ({
1118
+ description: "Records which Attentive segment a Drawbridge segment's contacts are added to.",
1119
+ hook: "segment.register",
1120
+ key: "Attentive Segment Register",
1121
+ queue: "connection",
1122
+ system: true,
1123
+ trigger: { event: "segment.register", type: "event" }
1124
+ })
922
1125
  }
923
1126
  },
924
1127
  // WHY, in the merchant's words, and what to do about it.
@@ -935,14 +1138,28 @@ var attentive_default2 = {
935
1138
  }
936
1139
  ];
937
1140
  },
938
- title: "Attentive"
1141
+ title: "Attentive",
1142
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1143
+ // only identifier we hold is the API's externalId, which their UI may not
1144
+ // path by — so this lands on the list, where the merchant finds it by name.
1145
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1146
+ //
1147
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1148
+ // segment url carries. What is on record is that the segments area lives at
1149
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1150
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1151
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1152
+ // walk-through confirms this against a real account before promote.
1153
+ urls: {
1154
+ segment: () => "https://ui.attentivemobile.com/segments/all"
1155
+ }
939
1156
  };
940
1157
 
941
1158
  // lib/connections/providers/drawbridge.js
942
- var import_node_crypto3 = require("crypto");
1159
+ var import_node_crypto4 = require("crypto");
943
1160
 
944
1161
  // lib/connections/inbound.js
945
- var import_node_crypto2 = require("crypto");
1162
+ var import_node_crypto3 = require("crypto");
946
1163
  var verifySignature = ({ body, descriptor, headers, secret }) => {
947
1164
  if (!secret) {
948
1165
  throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
@@ -951,10 +1168,10 @@ var verifySignature = ({ body, descriptor, headers, secret }) => {
951
1168
  if (!provided) {
952
1169
  throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
953
1170
  }
954
- const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
1171
+ const digest = (0, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
955
1172
  const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
956
1173
  const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
957
- if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
1174
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
958
1175
  throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
959
1176
  }
960
1177
  return JSON.parse(body.toString());
@@ -979,7 +1196,7 @@ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
979
1196
  throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
980
1197
  }
981
1198
  const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
982
- const verified = (0, import_node_crypto2.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
1199
+ const verified = (0, import_node_crypto3.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
983
1200
  if (!verified) {
984
1201
  throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
985
1202
  }
@@ -1614,10 +1831,10 @@ var free = {
1614
1831
  };
1615
1832
  var plans = {
1616
1833
  DB00002: {
1617
- // A verified sending domain is a PAID capability: free plans cannot send
1618
- // lead-facing email at all (the send path gates on an active
1619
- // subscription), so granting it there would offer a domain that can
1620
- // never send from.
1834
+ // A verified sending domain is a PAID capability. Every plan sends
1835
+ // lead-facing email from the platform address — the send is billed as an
1836
+ // action, so the allowance is the entitlement and sending from your own
1837
+ // domain is what the paid tiers add on top.
1621
1838
  features: all.features([organization.networking.key, organization.members.key]),
1622
1839
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
1623
1840
  marketing: {
@@ -2055,7 +2272,7 @@ var drawbridge_default2 = {
2055
2272
  content: {
2056
2273
  confirm: "This connection is part of Drawbridge and cannot be disconnected.",
2057
2274
  description: [
2058
- "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."
2275
+ "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2059
2276
  ],
2060
2277
  excerpt: "The steps Drawbridge runs itself.",
2061
2278
  guide: [
@@ -2221,16 +2438,6 @@ var drawbridge_default2 = {
2221
2438
  const request2 = { to };
2222
2439
  const { ok: sendable } = await canSend({ channel: "email", to });
2223
2440
  if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
2224
- const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
2225
- const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
2226
- if ((subscription == null ? void 0 : subscription.status) !== "active") {
2227
- return {
2228
- message: "Organization has no active subscription \u2014 workflow-step email skipped.",
2229
- request: request2,
2230
- response: { skipped: true },
2231
- skipped: true
2232
- };
2233
- }
2234
2441
  return {
2235
2442
  message: "Email queued for delivery to " + to + ".",
2236
2443
  request: request2,
@@ -2363,9 +2570,9 @@ var drawbridge_default2 = {
2363
2570
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
2364
2571
  const params = new URLSearchParams(String(body || ""));
2365
2572
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
2366
- const expected = (0, import_node_crypto3.createHmac)("sha1", secret).update(signed).digest("base64");
2573
+ const expected = (0, import_node_crypto4.createHmac)("sha1", secret).update(signed).digest("base64");
2367
2574
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2368
- const matches = expected.length === provided.length && (0, import_node_crypto3.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2575
+ const matches = expected.length === provided.length && (0, import_node_crypto4.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2369
2576
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2370
2577
  return Object.fromEntries(params);
2371
2578
  }
@@ -2378,6 +2585,11 @@ var drawbridge_default2 = {
2378
2585
  promotions: false
2379
2586
  },
2380
2587
  segment: {
2588
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2589
+ // vendor, and this manifest has no vendor behind it — the three that do
2590
+ // implement these.
2591
+ register: false,
2592
+ remove: false,
2381
2593
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2382
2594
  // contact in an organization against every segment, which is too much for
2383
2595
  // one job, so it returns chunks and the shell defers completion.
@@ -2653,6 +2865,33 @@ var drawbridge_default2 = {
2653
2865
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2654
2866
  // empty the moment this was added.
2655
2867
  requires: [],
2868
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
2869
+ // manifest, so `false` would be a lie about which reads were made.
2870
+ //
2871
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
2872
+ // citation here would evidence one of them and read as though it covered all
2873
+ // three, which is the omission this key exists to catch.
2874
+ review: {
2875
+ api: {
2876
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
2877
+ hubspot: "https://developers.hubspot.com/docs/reference/api/crm/objects/contacts",
2878
+ // lib/sendgrid.js posts to /v3/mail/send.
2879
+ sendgrid: "https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send",
2880
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
2881
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
2882
+ twilio: "https://www.twilio.com/docs/messaging/api/message-resource"
2883
+ },
2884
+ dashboard: {
2885
+ hubspot: "https://knowledge.hubspot.com/contacts/create-contacts",
2886
+ sendgrid: "https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed",
2887
+ twilio: "https://www.twilio.com/docs/messaging/guides/debugging-tools"
2888
+ },
2889
+ // An admin types these keys in; there is no merchant consent and no scope
2890
+ // model on any of the three.
2891
+ scopes: false,
2892
+ content: "2026-09-11",
2893
+ verified: null
2894
+ },
2656
2895
  slug: "drawbridge",
2657
2896
  // Always on. There is no credential that could go bad and no configuration a
2658
2897
  // merchant could leave half-finished.
@@ -2844,6 +3083,8 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
2844
3083
  }
2845
3084
  return response.status === 204 ? null : response.json();
2846
3085
  };
3086
+ var segmentName = (title) => "Drawbridge: " + title;
3087
+ var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
2847
3088
  var klaviyo_default2 = {
2848
3089
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
2849
3090
  // exchange without a code_verifier matching the challenge the consent
@@ -2873,9 +3114,21 @@ var klaviyo_default2 = {
2873
3114
  // exchange, and a copy here would be a second answer that goes stale.
2874
3115
  expiry: 90 * 24 * 60 * 60,
2875
3116
  pkce: true,
3117
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
3118
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
3119
+ // and set them using a space-separated list"
3120
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
3121
+ // 2026-09-11) — and a merchant's token only ever carries what they
3122
+ // consented to, so a scope added later is a reconnect for every one of
3123
+ // them. That is what segments cost when they were left out here.
3124
+ //
2876
3125
  // Space separated. accounts:read is required by Klaviyo on every app
2877
- // and must stay in the list; the rest are what a contact sync needs.
2878
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
3126
+ // and must stay in the list; the rest are what a contact sync and the
3127
+ // segment hooks need — Get Segments lists `segments:read`, Create,
3128
+ // Update and Delete Segment each list `segments:write`
3129
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3130
+ // revision 2026-07-15, fetched 2026-09-11).
3131
+ scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
2879
3132
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
2880
3133
  // the disconnect hook — three vendor addresses, two of them declared,
2881
3134
  // which is exactly the kind of split that goes unnoticed.
@@ -2924,9 +3177,10 @@ var klaviyo_default2 = {
2924
3177
  // Shown at disconnect, so it says what is lost and what is not.
2925
3178
  confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
2926
3179
  description: [
2927
- "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.",
3180
+ "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.",
2928
3181
  "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.",
2929
- "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."
3182
+ "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.",
3183
+ "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."
2930
3184
  ],
2931
3185
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
2932
3186
  // likely to grow — resources.* has already earned somewhere to put "we
@@ -3121,7 +3375,14 @@ var klaviyo_default2 = {
3121
3375
  // `segments` is null when the run carried no contact document,
3122
3376
  // meaning nobody looked — different from [], which means they
3123
3377
  // are in none. Null omits the key and merge leaves it alone.
3124
- ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
3378
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) },
3379
+ // THE IDS, which is what a Drawbridge-made segment's definition
3380
+ // filters on. Ids rather than titles, so renaming a segment is a
3381
+ // name change at Klaviyo and not a resync of every profile.
3382
+ //
3383
+ // The titles stay beside them: merchants have been building
3384
+ // their own segments on that array since it shipped.
3385
+ ...segments && { drawbridge_segment_ids: segments.map((entry) => entry.id).filter(Boolean) }
3125
3386
  }
3126
3387
  },
3127
3388
  type: "profile"
@@ -3165,17 +3426,141 @@ var klaviyo_default2 = {
3165
3426
  };
3166
3427
  }
3167
3428
  },
3168
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3169
- // register nothing with it, so listing four falses would be noise around a
3170
- // single decision. Still explicit absence would not say whether anybody
3171
- // considered it.
3172
- // Drawbridge sends its own notification email and SMS, and owns its own
3173
- // segments — see the private `drawbridge` manifest. A vendor answering
3174
- // these would be a second sender, which is the arrangement the platform
3175
- // sender replaced.
3429
+ // Drawbridge sends its own notification email. A vendor answering this
3430
+ // would be a second sender, which is the arrangement the platform sender
3431
+ // replaced. Declined as one line rather than one per verb, because the whole
3432
+ // domain is one decision — still explicit, since absence would not say
3433
+ // whether anybody considered it.
3176
3434
  email: false,
3177
- segment: false,
3435
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3436
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3437
+ // below — while register and remove keep a Klaviyo segment standing for
3438
+ // each Drawbridge segment, so the merchant can target one in their own
3439
+ // flows.
3440
+ segment: {
3441
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3442
+ //
3443
+ // Klaviyo owns no writable membership — its segments are computed from
3444
+ // rules — so the segment we create is DEFINED BY the profile property
3445
+ // contacts.sync writes. The definition filters on the Drawbridge
3446
+ // segment's ID, never its title, which is what makes a rename one PATCH
3447
+ // instead of a resync of every profile in it.
3448
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
3449
+ var _a, _b, _c, _d, _e;
3450
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
3451
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
3452
+ if (!canManageSegments(settings)) {
3453
+ return {
3454
+ message: "Reconnect Klaviyo to let Drawbridge manage segments \u2014 this connection was made before that permission was asked for.",
3455
+ skipped: true
3456
+ };
3457
+ }
3458
+ const name = segmentName(segment.title);
3459
+ const existing = segmentRowFor({ connection: connection2, segment });
3460
+ let id = null;
3461
+ if (existing == null ? void 0 : existing.id) {
3462
+ try {
3463
+ const found = await api2("/segments/" + existing.id, { fetcher, token });
3464
+ id = ((_a = found == null ? void 0 : found.data) == null ? void 0 : _a.id) ?? existing.id;
3465
+ if (((_c = (_b = found == null ? void 0 : found.data) == null ? void 0 : _b.attributes) == null ? void 0 : _c.name) !== name) {
3466
+ await api2("/segments/" + existing.id, {
3467
+ fetcher,
3468
+ method: "PATCH",
3469
+ payload: { data: { attributes: { name }, id: existing.id, type: "segment" } },
3470
+ token
3471
+ });
3472
+ }
3473
+ } catch (error) {
3474
+ if (error.status !== 404) throw error;
3475
+ id = null;
3476
+ }
3477
+ }
3478
+ if (!id) {
3479
+ const search = await api2("/segments?filter=" + encodeURIComponent('equals(name,"' + name.replace(/"/g, '\\"') + '")'), { fetcher, token });
3480
+ id = ((_d = ((search == null ? void 0 : search.data) || []).find((entry) => {
3481
+ var _a2;
3482
+ return ((_a2 = entry == null ? void 0 : entry.attributes) == null ? void 0 : _a2.name) === name;
3483
+ })) == null ? void 0 : _d.id) ?? null;
3484
+ }
3485
+ if (!id) {
3486
+ const created = await api2("/segments", {
3487
+ fetcher,
3488
+ method: "POST",
3489
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
3490
+ // — `name` and `definition` are both required on its attributes
3491
+ // — and a custom profile property is addressed as
3492
+ // "properties['property name']", tested with a list filter whose
3493
+ // operator is `contains`
3494
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3495
+ // revision 2026-07-15, fetched 2026-09-11).
3496
+ payload: {
3497
+ data: {
3498
+ attributes: {
3499
+ definition: {
3500
+ condition_groups: [{
3501
+ conditions: [{
3502
+ filter: { operator: "contains", type: "list", value: segment.id },
3503
+ property: "properties['drawbridge_segment_ids']",
3504
+ type: "profile-property"
3505
+ }]
3506
+ }]
3507
+ },
3508
+ name
3509
+ },
3510
+ type: "segment"
3511
+ }
3512
+ },
3513
+ token
3514
+ });
3515
+ id = (_e = created == null ? void 0 : created.data) == null ? void 0 : _e.id;
3516
+ }
3517
+ if (!id) return { message: "Klaviyo returned no segment id.", skipped: true };
3518
+ return {
3519
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
3520
+ // coalescing job id, so the last thing this does is look again.
3521
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
3522
+ events: [{
3523
+ event: "organization.segments",
3524
+ payload: { id: segment.id },
3525
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
3526
+ }],
3527
+ message: 'Klaviyo is carrying this segment as "' + name + '".',
3528
+ writes: segmentRowWrites({
3529
+ connection: connection2,
3530
+ data: { ...connection2, settings },
3531
+ manifest,
3532
+ row: { id, type: "segment" },
3533
+ segment
3534
+ })
3535
+ };
3536
+ },
3537
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
3538
+ // copy, and it carries the row naming what to delete.
3539
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
3540
+ const segment = context == null ? void 0 : context.segment;
3541
+ const existing = segmentRowFor({ connection: connection2, segment });
3542
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Klaviyo was never carrying this segment.", skipped: true };
3543
+ if (!canManageSegments(settings)) {
3544
+ return { message: "Reconnect Klaviyo to let Drawbridge manage segments.", skipped: true };
3545
+ }
3546
+ try {
3547
+ await api2("/segments/" + existing.id, { fetcher, method: "DELETE", token });
3548
+ } catch (error) {
3549
+ if (error.status !== 404) throw error;
3550
+ }
3551
+ return {
3552
+ message: "Klaviyo is no longer carrying this segment.",
3553
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
3554
+ };
3555
+ },
3556
+ // Drawbridge-side membership belongs to the private manifest.
3557
+ sync: false
3558
+ },
3559
+ // Declined for the same reason as `email` above: Drawbridge sends its own
3560
+ // notification SMS, and a vendor answering this would be a second sender.
3178
3561
  sms: false,
3562
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
3563
+ // to verify.
3179
3564
  inbound: false,
3180
3565
  // Nothing to set up or tear down at the vendor: the grant is the whole
3181
3566
  // integration. What CAN rot is the grant itself, so health is the one
@@ -3296,6 +3681,18 @@ var klaviyo_default2 = {
3296
3681
  "KLAVIYO_OAUTH_CLIENT_ID",
3297
3682
  "KLAVIYO_OAUTH_CLIENT_SECRET"
3298
3683
  ],
3684
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
3685
+ review: {
3686
+ api: "https://developers.klaviyo.com/en/reference/api_overview",
3687
+ dashboard: "https://help.klaviyo.com/hc/en-us/articles/115005078647",
3688
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
3689
+ // example scope string and nothing to check a manifest against; this page
3690
+ // lists the scopes each API takes, segments:read and segments:write among
3691
+ // them (fetched 2026-09-11).
3692
+ scopes: "https://developers.klaviyo.com/en/docs/authenticate_",
3693
+ content: "2026-09-11",
3694
+ verified: null
3695
+ },
3299
3696
  slug: "klaviyo",
3300
3697
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3301
3698
  // is already the merchant-facing copy channel and is already rendered.
@@ -3326,7 +3723,8 @@ var klaviyo_default2 = {
3326
3723
  hook: "lifecycle.health",
3327
3724
  key: "Klaviyo Connection Health",
3328
3725
  queue: "connection",
3329
- system: true
3726
+ system: true,
3727
+ trigger: { event: "day", type: "schedule" }
3330
3728
  })
3331
3729
  }
3332
3730
  },
@@ -3370,6 +3768,28 @@ var klaviyo_default2 = {
3370
3768
  usage: { actions: 1 }
3371
3769
  };
3372
3770
  }
3771
+ },
3772
+ segment: {
3773
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
3774
+ // these fire from the segment's own lifecycle, not from a workflow
3775
+ // somebody assembled. The trigger is declared here rather than hard-coded
3776
+ // in drawbridge-sync.
3777
+ register: () => ({
3778
+ description: "Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.",
3779
+ hook: "segment.register",
3780
+ key: "Klaviyo Segment Register",
3781
+ queue: "connection",
3782
+ system: true,
3783
+ trigger: { event: "segment.register", type: "event" }
3784
+ }),
3785
+ remove: () => ({
3786
+ description: "Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.",
3787
+ hook: "segment.remove",
3788
+ key: "Klaviyo Segment Remove",
3789
+ queue: "connection",
3790
+ system: true,
3791
+ trigger: { event: "segment.remove", type: "event" }
3792
+ })
3373
3793
  }
3374
3794
  },
3375
3795
  // WHY, in the merchant's words, and what to do about it.
@@ -3379,18 +3799,36 @@ var klaviyo_default2 = {
3379
3799
  // moment: the grant is good and the list is the missing half.
3380
3800
  tasks: (data2) => {
3381
3801
  var _a;
3382
- return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
3383
- {
3802
+ if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3803
+ return [
3804
+ // A connection made before segments were requested is authenticated and
3805
+ // cannot manage them, and no error surfaces anywhere else — the register
3806
+ // runs skip rather than fail.
3807
+ ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3808
+ message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3809
+ title: "Reconnect Klaviyo"
3810
+ }],
3811
+ ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3384
3812
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3385
3813
  title: "Choose a list"
3386
- }
3814
+ }]
3387
3815
  ];
3388
3816
  },
3389
- title: "Klaviyo"
3817
+ title: "Klaviyo",
3818
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
3819
+ // is its own help centre on a list: "you can find a list's ID in the URL in
3820
+ // your browser when viewing this list"
3821
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
3822
+ // segment's page is the sibling form of it. The path itself is NOT published
3823
+ // anywhere citable, so the dev walk-through confirms this against a real
3824
+ // account before promote.
3825
+ urls: {
3826
+ segment: (row2) => (row2 == null ? void 0 : row2.id) ? "https://www.klaviyo.com/segment/" + row2.id : null
3827
+ }
3390
3828
  };
3391
3829
 
3392
3830
  // lib/connections/providers/mailchimp.js
3393
- var import_node_crypto4 = require("crypto");
3831
+ var import_node_crypto5 = require("crypto");
3394
3832
 
3395
3833
  // lib/connections/icons/mailchimp.js
3396
3834
  var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -3422,7 +3860,8 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3422
3860
  }
3423
3861
  return response.status === 204 ? null : response.json();
3424
3862
  };
3425
- var subscriberHash = (email) => (0, import_node_crypto4.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3863
+ var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3864
+ var tagName = (title) => "Drawbridge: " + title;
3426
3865
  var mailchimp_default2 = {
3427
3866
  // OAUTH 2, authorization code. Every url below is quoted from
3428
3867
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3465,7 +3904,7 @@ var mailchimp_default2 = {
3465
3904
  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.",
3466
3905
  description: [
3467
3906
  "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.",
3468
- "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.",
3907
+ "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.",
3469
3908
  "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.",
3470
3909
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3471
3910
  ],
@@ -3478,6 +3917,7 @@ var mailchimp_default2 = {
3478
3917
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3479
3918
  "You come back here to pick the audience your contacts should sync into.",
3480
3919
  "The connection shows Pending until you pick an audience, then Active.",
3920
+ '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.',
3481
3921
  "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3482
3922
  ]
3483
3923
  },
@@ -3503,10 +3943,9 @@ var mailchimp_default2 = {
3503
3943
  }
3504
3944
  ],
3505
3945
  group: "contacts",
3506
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
3507
- // else is built yet, because audience sync has not shipped. Every false here
3508
- // is "not yet" rather than "never" when the sync lands, probe and
3509
- // contacts.sync are the first to flip.
3946
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
3947
+ // a paragraph up here that goes stale the moment one of them is implemented
3948
+ // which is exactly what happened to the note this replaces.
3510
3949
  hooks: {
3511
3950
  auth: {
3512
3951
  // WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
@@ -3560,26 +3999,32 @@ var mailchimp_default2 = {
3560
3999
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3561
4000
  // Marketing API reference for the list-members resource.
3562
4001
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3563
- var _a, _b;
4002
+ var _a, _b, _c;
3564
4003
  const audience = settings == null ? void 0 : settings.audience;
3565
4004
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3566
4005
  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);
3567
4006
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3568
4007
  const hash = subscriberHash(email);
4008
+ const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4009
+ const lastName = restOfName.join(" ");
4010
+ const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
4011
+ const mergeFields = {
4012
+ ...firstName && { FNAME: firstName },
4013
+ ...lastName && { LNAME: lastName },
4014
+ ...phone && { PHONE: phone }
4015
+ };
3569
4016
  const member = await api3("/lists/" + audience + "/members/" + hash, {
3570
4017
  dc: settings == null ? void 0 : settings.dc,
3571
4018
  fetcher,
3572
4019
  method: "PUT",
3573
4020
  payload: {
3574
4021
  email_address: email,
3575
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
3576
- // schemaless a merge tag that does not exist on the audience is
3577
- // refused, taking the whole request with it and FNAME is one of
3578
- // the two tags every audience is created with. The Drawbridge
3579
- // totals Klaviyo receives cannot travel until something registers
3580
- // merge fields on the chosen audience, which is lifecycle.register's
3581
- // job and is not built.
3582
- ...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
4022
+ // Built above. Omitted entirely when there is nothing to say, so a
4023
+ // lead with only an address does not send an empty object. The
4024
+ // Drawbridge totals Klaviyo receives still cannot travel this way
4025
+ // those are custom tags, and registering them on the chosen audience
4026
+ // is lifecycle.register's job and is not built.
4027
+ ...Object.keys(mergeFields).length > 0 && { merge_fields: mergeFields },
3583
4028
  ...suppressed && { status: "unsubscribed" },
3584
4029
  status_if_new: suppressed ? "unsubscribed" : "subscribed"
3585
4030
  },
@@ -3602,7 +4047,7 @@ var mailchimp_default2 = {
3602
4047
  });
3603
4048
  const joined = new Set(segments.map((entry) => entry.title));
3604
4049
  const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3605
- name: "Drawbridge: " + title,
4050
+ name: tagName(title),
3606
4051
  status: joined.has(title) ? "active" : "inactive"
3607
4052
  }));
3608
4053
  if (tags.length > 0) {
@@ -3625,12 +4070,120 @@ var mailchimp_default2 = {
3625
4070
  };
3626
4071
  }
3627
4072
  },
3628
- // Drawbridge sends its own notification email and SMS, and owns its own
3629
- // segments see the private `drawbridge` manifest. A vendor answering
3630
- // these would be a second sender, which is the arrangement the platform
3631
- // sender replaced.
4073
+ // Drawbridge sends its own notification email. A vendor answering this
4074
+ // would be a second sender, which is the arrangement the platform sender
4075
+ // replaced.
3632
4076
  email: false,
3633
- segment: false,
4077
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
4078
+ // membership — see the private `drawbridge` manifest, and `sync : false`
4079
+ // below — while register and remove keep a Mailchimp tag standing for each
4080
+ // Drawbridge segment, so the merchant can target one in their own audience.
4081
+ segment: {
4082
+ // THE TAG THIS SEGMENT IS, held by id at last.
4083
+ //
4084
+ // Tags ARE static segments in Mailchimp's model — same collection, same
4085
+ // ids — so this creates one through /segments and the member write goes
4086
+ // on attaching people to it by name. Both address the same object. The
4087
+ // segment schema says it outright: "The type of segment. Static segments
4088
+ // are now known as tags"
4089
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
4090
+ //
4091
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4092
+ // sweep and on backfill, it converges. That is what lets one hook serve
4093
+ // all four without a create-vs-update branch anywhere else.
4094
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
4095
+ var _a;
4096
+ const audience = settings == null ? void 0 : settings.audience;
4097
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4098
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4099
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4100
+ const name = tagName(segment.title);
4101
+ const existing = segmentRowFor({ connection: connection2, segment });
4102
+ let id = null;
4103
+ if (existing == null ? void 0 : existing.id) {
4104
+ try {
4105
+ const found = await api3("/lists/" + audience + "/segments/" + existing.id, { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4106
+ id = (found == null ? void 0 : found.id) ?? existing.id;
4107
+ if ((found == null ? void 0 : found.name) !== name) {
4108
+ await api3("/lists/" + audience + "/segments/" + existing.id, {
4109
+ dc: settings == null ? void 0 : settings.dc,
4110
+ fetcher,
4111
+ method: "PATCH",
4112
+ payload: { name },
4113
+ token
4114
+ });
4115
+ }
4116
+ } catch (error) {
4117
+ if (error.status !== 404) throw error;
4118
+ id = null;
4119
+ }
4120
+ }
4121
+ if (!id) {
4122
+ const search = await api3("/lists/" + audience + "/tag-search?name=" + encodeURIComponent(name), { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4123
+ id = ((_a = ((search == null ? void 0 : search.tags) || []).find((tag) => (tag == null ? void 0 : tag.name) === name)) == null ? void 0 : _a.id) ?? null;
4124
+ }
4125
+ if (!id) {
4126
+ const created = await api3("/lists/" + audience + "/segments", {
4127
+ dc: settings == null ? void 0 : settings.dc,
4128
+ fetcher,
4129
+ method: "POST",
4130
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
4131
+ // name; this call only has to make the object exist. Mailchimp's
4132
+ // own wording for the empty array: "Passing an empty array will
4133
+ // create a static segment without any subscribers."
4134
+ payload: { name, static_segment: [] },
4135
+ token
4136
+ });
4137
+ id = created == null ? void 0 : created.id;
4138
+ }
4139
+ if (!id) return { message: "Mailchimp returned no tag id.", skipped: true };
4140
+ const audienceDetail = await api3("/lists/" + audience + "?fields=web_id", { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4141
+ return {
4142
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4143
+ // coalescing job id, so the last thing this does is look again.
4144
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
4145
+ events: [{
4146
+ event: "organization.segments",
4147
+ payload: { id: segment.id },
4148
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
4149
+ }],
4150
+ message: 'Mailchimp is carrying this segment as the tag "' + name + '".',
4151
+ writes: segmentRowWrites({
4152
+ connection: connection2,
4153
+ data: { ...connection2, settings },
4154
+ manifest,
4155
+ row: { id, type: "tag", webId: audienceDetail == null ? void 0 : audienceDetail.web_id },
4156
+ segment
4157
+ })
4158
+ };
4159
+ },
4160
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
4161
+ // whole pair exists to stop — every member would keep a label for a
4162
+ // segment that no longer exists.
4163
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
4164
+ const segment = context == null ? void 0 : context.segment;
4165
+ const existing = segmentRowFor({ connection: connection2, segment });
4166
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Mailchimp was never carrying this segment.", skipped: true };
4167
+ try {
4168
+ await api3("/lists/" + (settings == null ? void 0 : settings.audience) + "/segments/" + existing.id, {
4169
+ dc: settings == null ? void 0 : settings.dc,
4170
+ fetcher,
4171
+ method: "DELETE",
4172
+ token
4173
+ });
4174
+ } catch (error) {
4175
+ if (error.status !== 404) throw error;
4176
+ }
4177
+ return {
4178
+ message: "Mailchimp is no longer carrying this segment.",
4179
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
4180
+ };
4181
+ },
4182
+ // Drawbridge-side membership belongs to the private manifest.
4183
+ sync: false
4184
+ },
4185
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4186
+ // notification SMS, and a vendor answering this would be a second sender.
3634
4187
  sms: false,
3635
4188
  inbound: false,
3636
4189
  lifecycle: false,
@@ -3693,6 +4246,16 @@ var mailchimp_default2 = {
3693
4246
  "MAILCHIMP_OAUTH_CLIENT_ID",
3694
4247
  "MAILCHIMP_OAUTH_CLIENT_SECRET"
3695
4248
  ],
4249
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4250
+ review: {
4251
+ api: "https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/",
4252
+ dashboard: "https://mailchimp.com/help/manage-tags/",
4253
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
4254
+ // account-wide — so there is nothing to request and nothing to re-consent.
4255
+ scopes: false,
4256
+ content: "2026-09-11",
4257
+ verified: null
4258
+ },
3696
4259
  slug: "mailchimp",
3697
4260
  // A grant with no audience chosen is authenticated and useless — the sync has
3698
4261
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -3738,6 +4301,30 @@ var mailchimp_default2 = {
3738
4301
  // adds this step, and what is charged when it runs.
3739
4302
  usage: { actions: 1 }
3740
4303
  })
4304
+ },
4305
+ segment: {
4306
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4307
+ // these fire from the segment's own lifecycle, not from a workflow
4308
+ // somebody assembled.
4309
+ //
4310
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
4311
+ // which is what lets a vendor arrive with its own without a queue edit.
4312
+ register: () => ({
4313
+ description: "Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.",
4314
+ hook: "segment.register",
4315
+ key: "Mailchimp Segment Register",
4316
+ queue: "connection",
4317
+ system: true,
4318
+ trigger: { event: "segment.register", type: "event" }
4319
+ }),
4320
+ remove: () => ({
4321
+ description: "Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.",
4322
+ hook: "segment.remove",
4323
+ key: "Mailchimp Segment Remove",
4324
+ queue: "connection",
4325
+ system: true,
4326
+ trigger: { event: "segment.remove", type: "event" }
4327
+ })
3741
4328
  }
3742
4329
  },
3743
4330
  // WHY, in the merchant's words, and what to do about it.
@@ -3754,11 +4341,27 @@ var mailchimp_default2 = {
3754
4341
  }
3755
4342
  ];
3756
4343
  },
3757
- title: "Mailchimp"
4344
+ title: "Mailchimp",
4345
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
4346
+ // the web_id field is "The ID used in the Mailchimp web application. View this
4347
+ // list in your Mailchimp account at
4348
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
4349
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
4350
+ // 2026-09-11).
4351
+ //
4352
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
4353
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
4354
+ // click short rather than guessing at one that could break silently.
4355
+ urls: {
4356
+ segment: (row2, data2) => {
4357
+ var _a;
4358
+ 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;
4359
+ }
4360
+ }
3758
4361
  };
3759
4362
 
3760
4363
  // lib/connections/providers/shopify.js
3761
- var import_node_crypto5 = require("crypto");
4364
+ var import_node_crypto6 = require("crypto");
3762
4365
  var import_nanoid3 = require("nanoid");
3763
4366
 
3764
4367
  // lib/connections/icons/shopify.js
@@ -3855,6 +4458,28 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3855
4458
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3856
4459
  );
3857
4460
  var generateDiscountCode = (0, import_nanoid3.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4461
+ var blockedReason = (discount) => {
4462
+ var _a;
4463
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4464
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4465
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4466
+ 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.";
4467
+ }
4468
+ ;
4469
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4470
+ return "This discount has reached its total usage limit.";
4471
+ }
4472
+ ;
4473
+ return null;
4474
+ };
4475
+ var discountWarning = (discount) => {
4476
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4477
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
4478
+ }
4479
+ ;
4480
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4481
+ return null;
4482
+ };
3858
4483
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3859
4484
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3860
4485
  var OAUTH_ERROR_SOURCE = "oauth";
@@ -3902,7 +4527,7 @@ var shopify_default2 = {
3902
4527
  description: [
3903
4528
  "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.",
3904
4529
  "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.",
3905
- "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."
4530
+ "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."
3906
4531
  ],
3907
4532
  errors: {
3908
4533
  connect: {
@@ -3916,7 +4541,7 @@ var shopify_default2 = {
3916
4541
  "Open the Drawbridge listing on the Shopify App Store.",
3917
4542
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3918
4543
  "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3919
- "Come back here \u2014 the connections list updates on its own once the install lands."
4544
+ "Come back here \u2014 the connections list updates on its own once the install finishes."
3920
4545
  ],
3921
4546
  // Names where the link GOES rather than what it does: installing happens on
3922
4547
  // the App Store listing, and the dashboard must never imply a store can be
@@ -4292,9 +4917,11 @@ var shopify_default2 = {
4292
4917
  phone: customerPhone
4293
4918
  } : null;
4294
4919
  const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
4295
- const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
4920
+ const createsOrder = !backfill && (isConversion || Boolean(discount));
4921
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (createsOrder ? mintId() : null);
4922
+ const redemptionDocId = discount ? mintId() : null;
4296
4923
  const writes = [];
4297
- if (isConversion && !backfill) {
4924
+ if (createsOrder) {
4298
4925
  writes.push({
4299
4926
  collection: "order",
4300
4927
  data: {
@@ -4315,15 +4942,25 @@ var shopify_default2 = {
4315
4942
  provider: { id: String(orderId), slug: "shopify" },
4316
4943
  purchasedAt,
4317
4944
  rate,
4945
+ // Null on a conversion that matched no code of ours; the
4946
+ // backfill branch below sets it when one arrives later.
4947
+ redemption: redemptionDocId,
4318
4948
  source,
4319
- status: "completed"
4949
+ status: "completed",
4950
+ type: isConversion ? "conversion" : "redemption"
4320
4951
  },
4321
4952
  operation: "create"
4322
4953
  });
4323
4954
  if (org == null ? void 0 : org.usage) {
4324
4955
  writes.push({
4325
4956
  collection: "usage",
4326
- data: { $inc: { "totals.revenue": gross } },
4957
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
4958
+ // conversion revenue and is the figure the fee is charged
4959
+ // against, so redemption money gets its own key rather than
4960
+ // changing what an existing number means.
4961
+ data: {
4962
+ $inc: isConversion ? { "totals.revenue": gross } : { "totals.redemptionRevenue": gross }
4963
+ },
4327
4964
  operation: "update",
4328
4965
  query: { id: org.usage }
4329
4966
  });
@@ -4331,7 +4968,12 @@ var shopify_default2 = {
4331
4968
  if (leadId) {
4332
4969
  writes.push({
4333
4970
  collection: "lead",
4334
- data: { $inc: { "totals.orders": 1 } },
4971
+ // Same grouped shape the contact carries, so a lead and the
4972
+ // contact built from it cannot be read two different ways.
4973
+ data: { $inc: {
4974
+ "totals.orders.total": 1,
4975
+ ...isConversion ? { "totals.orders.conversions": 1 } : { "totals.orders.redemptions": 1 }
4976
+ } },
4335
4977
  operation: "update",
4336
4978
  options: { bypassDocumentValidation: true },
4337
4979
  query: { id: leadId }
@@ -4350,6 +4992,7 @@ var shopify_default2 = {
4350
4992
  customer,
4351
4993
  discount,
4352
4994
  gross,
4995
+ id: redemptionDocId,
4353
4996
  lead: leadId,
4354
4997
  order: orderDocId,
4355
4998
  organization: campaignOrganization,
@@ -4378,6 +5021,14 @@ var shopify_default2 = {
4378
5021
  query: { id: leadId }
4379
5022
  });
4380
5023
  }
5024
+ if (backfill && orderDocId && redemptionDocId) {
5025
+ writes.push({
5026
+ collection: "order",
5027
+ data: { $set: { redemption: redemptionDocId } },
5028
+ operation: "update",
5029
+ query: { id: orderDocId }
5030
+ });
5031
+ }
4381
5032
  }
4382
5033
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4383
5034
  const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
@@ -4756,7 +5407,7 @@ var shopify_default2 = {
4756
5407
  event: "shopify.register.webhooks"
4757
5408
  },
4758
5409
  name: "register",
4759
- options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
5410
+ options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
4760
5411
  queue: "connection"
4761
5412
  }],
4762
5413
  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." : ""),
@@ -4870,10 +5521,19 @@ var shopify_default2 = {
4870
5521
  // picker stores, which is why the tail is taken here rather than by
4871
5522
  // each caller that happened to remember.
4872
5523
  items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
4873
- var _a2, _b2, _c;
5524
+ var _a2, _b2;
5525
+ const node = ((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.codeDiscount) || {};
4874
5526
  return {
4875
- id: String(((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id) || "").split("/").pop(),
4876
- title: (_c = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.codeDiscount) == null ? void 0 : _c.title
5527
+ // Null when the discount can be used, a sentence when it cannot.
5528
+ // The picker greys the row and shows this instead of hiding it:
5529
+ // a discount the merchant can see in Shopify admin, missing here
5530
+ // with no explanation, reads as a bug in us.
5531
+ blocked: blockedReason(node),
5532
+ id: String(((_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.id) || "").split("/").pop(),
5533
+ // Usable, but not in the way the merchant probably expects.
5534
+ // Shown beside the row without stopping them.
5535
+ warning: discountWarning(node),
5536
+ title: node.title
4877
5537
  };
4878
5538
  }),
4879
5539
  pageInfo: {
@@ -4888,20 +5548,6 @@ var shopify_default2 = {
4888
5548
  },
4889
5549
  icon: shopify_default,
4890
5550
  inbound,
4891
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
4892
- //
4893
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
4894
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
4895
- // through, which is the arrangement these manifests exist to remove.
4896
- //
4897
- // Undefined until a shop is linked, so the Manage button only appears on a
4898
- // connected connection. The app handle is NAMED by `requires` and read from
4899
- // the env the resolver passes, never from process.env here.
4900
- manage: (data2, env) => {
4901
- var _a;
4902
- const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
4903
- return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
4904
- },
4905
5551
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
4906
5552
  // what an admin types on the provider screen. The four names below are exactly
4907
5553
  // what `requires` gates on, which is the point of declaring them together: a
@@ -4947,6 +5593,14 @@ var shopify_default2 = {
4947
5593
  "SHOPIFY_APP_LISTING_URL",
4948
5594
  "SHOPIFY_APP_HANDLE"
4949
5595
  ],
5596
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5597
+ review: {
5598
+ api: "https://shopify.dev/docs/api/admin-graphql",
5599
+ dashboard: "https://help.shopify.com/en/manual/apps",
5600
+ scopes: "https://shopify.dev/docs/api/usage/access-scopes",
5601
+ content: "2026-09-11",
5602
+ verified: null
5603
+ },
4950
5604
  slug: "shopify",
4951
5605
  // The install is the whole configuration — Shopify hands back the shop and
4952
5606
  // there is nothing further to choose. `shop` absent means the install did not
@@ -5034,8 +5688,11 @@ var shopify_default2 = {
5034
5688
  })
5035
5689
  },
5036
5690
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
5037
- // in the builder, so they carry no trigger and no usage. Declared because
5038
- // the routing table and the system-workflow descriptions both read here.
5691
+ // in the builder, so they carry no usage. These two are fired by a webhook
5692
+ // arriving rather than by a workflow trigger, so they name none either —
5693
+ // and naming none is what stops a workflow being provisioned for them.
5694
+ // Declared because the routing table and the system-workflow descriptions
5695
+ // both read here.
5039
5696
  order: {
5040
5697
  record: () => ({
5041
5698
  description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
@@ -5067,7 +5724,8 @@ var shopify_default2 = {
5067
5724
  hook: "lifecycle.health",
5068
5725
  key: "Shopify Connection Health",
5069
5726
  queue: "connection",
5070
- system: true
5727
+ system: true,
5728
+ trigger: { event: "day", type: "schedule" }
5071
5729
  })
5072
5730
  },
5073
5731
  // Audit-only. The "Shopify Token Activity" system workflow lists these
@@ -5128,11 +5786,34 @@ var shopify_default2 = {
5128
5786
  ] : []
5129
5787
  ];
5130
5788
  },
5131
- title: "Shopify"
5789
+ title: "Shopify",
5790
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
5791
+ // here rather than at the top level so a second link (a product, an order)
5792
+ // is a key in this object instead of a new manifest key nobody agreed on.
5793
+ //
5794
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
5795
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
5796
+ // vendor runs through, which is the arrangement these manifests exist to
5797
+ // remove.
5798
+ //
5799
+ // Never projected: the api composes connect.manage from it, and
5800
+ // resolveConnection drops the object, because a url built from settings is
5801
+ // built where the settings are already decrypted.
5802
+ urls: {
5803
+ // Undefined until a shop is linked, so the Manage button only appears on a
5804
+ // connected connection. The app handle is NAMED by `requires` and read from
5805
+ // the env its caller passes — the api's resolve() hands it the stored
5806
+ // credentials, never process.env.
5807
+ manage: (data2, env) => {
5808
+ var _a;
5809
+ const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
5810
+ return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
5811
+ }
5812
+ }
5132
5813
  };
5133
5814
 
5134
5815
  // lib/connections/providers/webhook.js
5135
- var import_node_crypto6 = __toESM(require("crypto"), 1);
5816
+ var import_node_crypto7 = __toESM(require("crypto"), 1);
5136
5817
 
5137
5818
  // lib/safe-http.js
5138
5819
  var import_dns2 = __toESM(require("dns"), 1);
@@ -5286,7 +5967,7 @@ var signature = ({ body, settings }) => {
5286
5967
  return [
5287
5968
  "t=" + timestamp,
5288
5969
  ...[settings.secret, ...previous].map(
5289
- (secret) => "v1=" + import_node_crypto6.default.createHmac("sha256", secret).update(payload).digest("hex")
5970
+ (secret) => "v1=" + import_node_crypto7.default.createHmac("sha256", secret).update(payload).digest("hex")
5290
5971
  )
5291
5972
  ].join(",");
5292
5973
  };
@@ -5308,7 +5989,7 @@ var webhook_default = {
5308
5989
  content: {
5309
5990
  confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
5310
5991
  description: [
5311
- "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
5992
+ "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
5312
5993
  "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."
5313
5994
  ],
5314
5995
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -5409,6 +6090,9 @@ var webhook_default = {
5409
6090
  // Gated on the encryption secret: without it the signing secret could not be
5410
6091
  // stored safely, so the connection must not be offered at all.
5411
6092
  requires: ["ENCRYPT_CONNECTION_SECRET"],
6093
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
6094
+ // to link to and no scope to request: connecting mints a secret.
6095
+ review: false,
5412
6096
  // Outbound only. inbound.* is false because the direction is the point: we
5413
6097
  // sign and POST to the merchant's endpoint, they never call us. Every other
5414
6098
  // false follows from there being no third party to authenticate against —
@@ -5798,7 +6482,7 @@ var redactSettings = ({ slug: slug2, settings }) => {
5798
6482
  var publicConnectionKeys = Object.freeze([
5799
6483
  "actions",
5800
6484
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
5801
- // auth.type, content.redirect and the manifest's manage() — the client reads
6485
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
5802
6486
  // connect.type to choose entered-vs-installed, connect.redirect for the App
5803
6487
  // Store link, connect.manage for the admin deep link. It was dropped from
5804
6488
  // this list when the manifests stopped declaring it, which stripped the
@@ -5853,7 +6537,7 @@ var projectConnection = (record) => {
5853
6537
  var resolveConnection = (item, data2, env = {}) => {
5854
6538
  if (!item) return item;
5855
6539
  return Object.fromEntries(
5856
- Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "provider", "requires", "status", "steps", "supports"].includes(key)).map(([key, value]) => [
6540
+ Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "provider", "requires", "review", "status", "steps", "supports", "urls"].includes(key)).map(([key, value]) => [
5857
6541
  key,
5858
6542
  typeof value === "function" ? value(data2, env) : value
5859
6543
  ])