@drawbridge/drawbridge-utils 0.0.118 → 0.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,10 @@ __export(connections_exports, {
32
32
  AUTH_TYPES: () => AUTH_TYPES,
33
33
  GROUPS: () => GROUPS,
34
34
  HOOKS: () => HOOKS,
35
+ HOOK_EFFECTS: () => HOOK_EFFECTS,
35
36
  HOOK_NAMES: () => HOOK_NAMES,
37
+ HOOK_OPTIONS: () => HOOK_OPTIONS,
38
+ HOOK_PROPS: () => HOOK_PROPS,
36
39
  INPUTS: () => INPUTS,
37
40
  OAUTH_FIELDS: () => OAUTH_FIELDS,
38
41
  OUTCOMES: () => OUTCOMES,
@@ -40,6 +43,7 @@ __export(connections_exports, {
40
43
  STATUSES: () => STATUSES,
41
44
  STEPS: () => STEPS,
42
45
  STEP_TYPES: () => STEP_TYPES,
46
+ WRITE_OPERATIONS: () => WRITE_OPERATIONS,
43
47
  accessToken: () => accessToken,
44
48
  authToken: () => authToken,
45
49
  availableConnections: () => availableConnections,
@@ -49,6 +53,7 @@ __export(connections_exports, {
49
53
  connectionSteps: () => connectionSteps,
50
54
  connections: () => connections,
51
55
  consentUrl: () => consentUrl,
56
+ effectsOf: () => effectsOf,
52
57
  hookSupport: () => hookSupport,
53
58
  isStale: () => isStale,
54
59
  mergeSettings: () => mergeSettings,
@@ -245,6 +250,89 @@ var HOOKS = Object.freeze({
245
250
  "promotions"
246
251
  ])
247
252
  });
253
+ var HOOK_EFFECTS = Object.freeze(["enqueues", "events", "writes"]);
254
+ var WRITE_OPERATIONS = Object.freeze(["create", "update"]);
255
+ var HOOK_PROPS = Object.freeze([
256
+ "channel",
257
+ "clientId",
258
+ "clientSecret",
259
+ "connection",
260
+ "contact",
261
+ "context",
262
+ "cursor",
263
+ "declaration",
264
+ "doc",
265
+ "email",
266
+ "event",
267
+ "headers",
268
+ "id",
269
+ "lead",
270
+ "limit",
271
+ "manifest",
272
+ "payload",
273
+ "scope",
274
+ "search",
275
+ "secret",
276
+ "settings",
277
+ "sort",
278
+ "step",
279
+ "suppressed",
280
+ "token",
281
+ "tokens",
282
+ "workflow"
283
+ ]);
284
+ var HOOK_OPTIONS = Object.freeze([
285
+ "adminToken",
286
+ "canSend",
287
+ "chunkSize",
288
+ "dispatch",
289
+ "fetcher",
290
+ "logger",
291
+ "mintId",
292
+ "read",
293
+ "reconcileScopes",
294
+ "request",
295
+ "resolveContact",
296
+ "resolveSettings",
297
+ "rotateToken",
298
+ "shopify"
299
+ ]);
300
+ var effectsOf = (answer) => {
301
+ for (const key of HOOK_EFFECTS) {
302
+ if ((answer == null ? void 0 : answer[key]) !== void 0 && !Array.isArray(answer[key])) {
303
+ throw new Error("A hook returned " + key + " that is not an array");
304
+ }
305
+ }
306
+ const enqueues = (answer == null ? void 0 : answer.enqueues) || [];
307
+ const events = (answer == null ? void 0 : answer.events) || [];
308
+ const writes = (answer == null ? void 0 : answer.writes) || [];
309
+ for (const write of writes) {
310
+ if (!(write == null ? void 0 : write.collection)) throw new Error("A described write names no collection");
311
+ if (!WRITE_OPERATIONS.includes(write == null ? void 0 : write.operation)) {
312
+ throw new Error("A described write on " + write.collection + " needs an operation \u2014 one of " + WRITE_OPERATIONS.join(", "));
313
+ }
314
+ if (!(write == null ? void 0 : write.data)) throw new Error("A described write on " + write.collection + " carries no data");
315
+ if (write.operation === "update" && !write.query) {
316
+ throw new Error("A described update on " + write.collection + " has no query \u2014 that is every document in it");
317
+ }
318
+ if (write.operation === "create" && write.query) {
319
+ throw new Error("A described create on " + write.collection + " carries a query \u2014 create does not filter");
320
+ }
321
+ }
322
+ for (const enqueue of enqueues) {
323
+ if (!(enqueue == null ? void 0 : enqueue.queue)) throw new Error("A described enqueue names no queue");
324
+ if (!(enqueue == null ? void 0 : enqueue.name)) throw new Error("A described enqueue on the " + enqueue.queue + " queue names no job");
325
+ }
326
+ for (const event of events) {
327
+ if (!(event == null ? void 0 : event.event)) throw new Error("A described event has no name");
328
+ if (!(event == null ? void 0 : event.room)) throw new Error("A described " + event.event + " event has no room");
329
+ }
330
+ const transaction = Boolean(answer == null ? void 0 : answer.transaction);
331
+ if (transaction && !writes.length) {
332
+ throw new Error("A hook asked for a transaction and described no writes");
333
+ }
334
+ return { enqueues, events, transaction, writes };
335
+ };
248
336
  var STEPS = Object.freeze({
249
337
  "commerce.code.issue": "Issue discount code",
250
338
  "commerce.customer.insert": "Create customer",
@@ -454,13 +542,56 @@ var attentive_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
454
542
  <path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
455
543
  </svg>`;
456
544
 
457
- // lib/connections/attentive.js
545
+ // lib/phone.js
546
+ var import_libphonenumber_js = require("libphonenumber-js");
547
+ var toE164 = (value, country) => {
548
+ if (!value) return null;
549
+ try {
550
+ const parsed = (0, import_libphonenumber_js.parsePhoneNumberFromString)(String(value), country);
551
+ return parsed ? parsed.number : null;
552
+ } catch {
553
+ return null;
554
+ }
555
+ };
556
+ var detectCountry = (value) => {
557
+ if (!value) return null;
558
+ try {
559
+ const parser = new import_libphonenumber_js.AsYouType();
560
+ parser.input(String(value));
561
+ return parser.getCountry() || null;
562
+ } catch {
563
+ return null;
564
+ }
565
+ };
566
+
567
+ // lib/connections/providers/attentive.js
568
+ var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
569
+ const response = await fetcher("https://api.attentivemobile.com" + path, {
570
+ ...payload && { body: JSON.stringify(payload) },
571
+ headers: {
572
+ authorization: "Bearer " + token,
573
+ ...payload && { "content-type": "application/json" }
574
+ },
575
+ method,
576
+ signal: AbortSignal.timeout(15e3)
577
+ });
578
+ if (!response.ok) {
579
+ throw Object.assign(
580
+ new Error("Attentive refused the request (" + response.status + ")"),
581
+ { status: response.status }
582
+ );
583
+ }
584
+ return response.json().catch(() => null);
585
+ };
458
586
  var attentive_default2 = {
459
587
  auth: {
460
588
  oauth: {
461
- // NAMES of the env vars holding OUR app's client — set at registration,
462
- // never before. No `headers` on the client: Attentive takes credentials
463
- // as form fields, which is the runner's default.
589
+ // NAMES of the credentials holding OUR app's client — keys into the map
590
+ // the provider collection answers, entered on the admin screen at
591
+ // registration, never before. (The names are the env vars they once
592
+ // were; the vocabulary stayed when the storage moved.) No `headers` on
593
+ // the client: Attentive takes credentials as form fields, which is the
594
+ // runner's default.
464
595
  client: {
465
596
  id: "ATTENTIVE_OAUTH_CLIENT_ID",
466
597
  secret: "ATTENTIVE_OAUTH_CLIENT_SECRET"
@@ -485,9 +616,10 @@ var attentive_default2 = {
485
616
  // has to say so rather than let them believe otherwise.
486
617
  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.",
487
618
  description: [
488
- "Attentive is where your SMS marketing lives, and this connection is becoming the way your Drawbridge contacts sync into an Attentive segment.",
619
+ "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.",
489
620
  "You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
490
- "Subscriber syncing is not live yet, so connecting today does nothing except choose the segment it will use when it ships."
621
+ "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.",
622
+ "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."
491
623
  ],
492
624
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
493
625
  guide: [
@@ -508,6 +640,10 @@ var attentive_default2 = {
508
640
  message: "Contacts your campaigns collect are synced into this segment.",
509
641
  hook: "resources.audiences",
510
642
  required: true
643
+ // CONSUMED BY THE MEMBERSHIP CALL, not by the subscribe. Attentive's
644
+ // /v1/subscriptions takes no segment id — subscription and segment
645
+ // membership are two operations here — so contacts.sync makes both calls
646
+ // and this value is the externalId the second one carries.
511
647
  // No `search : false` here, and that is a first: /v2/segments takes a
512
648
  // `name` filter (partial match, cited above), so this picker searches
513
649
  // the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
@@ -520,17 +656,25 @@ var attentive_default2 = {
520
656
  // and contacts.sync are the first to flip.
521
657
  hooks: {
522
658
  auth: {
523
- // The exchange already yields the tokens, and Attentive documents no
524
- // account-identity endpoint to enrich them with — Klaviyo's connect
525
- // reads the account name back; this has nothing cited to read. The
526
- // callback stores the tokens and skips enrichment on `unimplemented`.
527
659
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
528
- // dependencies", and nothing anywhere implements either of these —
529
- // there is nothing for them to do. The exchange already yields the
530
- // tokens and Attentive documents no account-identity endpoint to
531
- // enrich them with, so connect has nothing to add; and they document
532
- // no revocation endpoint at all, so disconnect has nothing to call.
533
- // Recorded as a decision rather than left as an unkept promise.
660
+ // dependencies", and nothing anywhere implements either of these — there
661
+ // is nothing for them to do. They document no revocation endpoint at all,
662
+ // so disconnect has nothing to call. Recorded as a decision rather than
663
+ // left as an unkept promise.
664
+ //
665
+ // STILL FALSE AFTER LOOKING AGAIN, and this is the reason written down so
666
+ // nobody re-derives it. Klaviyo's connect reads the account name back so
667
+ // the card is not blank; Attentive's card stays blank. There IS an
668
+ // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
669
+ // on docs.attentive.com/pages/authentication/ as returning "information
670
+ // specific to your company" — but its RESPONSE SCHEMA is published
671
+ // nowhere we can read: the docs show the curl and no body. Reading
672
+ // `body.name` would be a guess, and a guess here fails at the worst
673
+ // moment, in the callback, after the merchant has already consented.
674
+ //
675
+ // A live token settles it in one call, alongside the three registration
676
+ // checks in the header. Until then the honest state is a blank field, not
677
+ // a hopeful one.
534
678
  connect: false,
535
679
  disconnect: false,
536
680
  probe: false,
@@ -552,7 +696,112 @@ var attentive_default2 = {
552
696
  }
553
697
  },
554
698
  commerce: false,
555
- contacts: { remove: false, sync: false },
699
+ // The verb the contacts.sync step points at.
700
+ contacts: {
701
+ // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
702
+ // different thing from erasing the subscriber — Attentive's deletion sits
703
+ // behind their privacy-request API, which is a different grant.
704
+ remove: false,
705
+ // TWO CALLS, BECAUSE ATTENTIVE HAS TWO IDEAS.
706
+ //
707
+ // Subscribing and being in a segment are NOT the same operation here —
708
+ // unlike Klaviyo, where a subscription is created against the list itself.
709
+ // /v1/subscriptions takes no segment id at all, so the segment a merchant
710
+ // picked on this connection can only be honoured by the bulk segment
711
+ // membership API:
712
+ //
713
+ // subscribe POST /v1/subscriptions
714
+ // { user : { email, phone }, locale, subscriptionType } — the
715
+ // docs require EITHER signUpSourceId OR (locale +
716
+ // subscriptionType), and we hold no sign-up source. 202.
717
+ //
718
+ // membership POST /v2/bulk/segments/members
719
+ // { externalId, members : [ { email, phone } ] }, 1-10,000
720
+ // members, 202 with a batchJobId
721
+ // (docs.attentive.com/reference/postbulksegmentmembers).
722
+ //
723
+ // unsubscribe POST /v1/subscriptions/unsubscribe
724
+ // { user, subscriptions : [ { type, channel } ] }. 202.
725
+ //
726
+ // EVERY ONE OF THEM ANSWERS 202 ACCEPTED, which means Attentive took the
727
+ // job, not that it ran — the same distinction the Shopify usage charge
728
+ // makes between a 202 and a charge. The message below says accepted, and
729
+ // must keep saying accepted.
730
+ sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
731
+ var _a, _b, _c, _d;
732
+ const segment = settings == null ? void 0 : settings.segment;
733
+ if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
734
+ 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);
735
+ const phone = toE164(((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.phone) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.phone));
736
+ if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", skipped: true };
737
+ const user = {
738
+ ...email && { email },
739
+ ...phone && { phone }
740
+ };
741
+ if (suppressed) {
742
+ await api("/v1/subscriptions/unsubscribe", {
743
+ fetcher,
744
+ method: "POST",
745
+ payload: {
746
+ // One entry per channel we can actually name them by. MARKETING
747
+ // is the only type Drawbridge ever subscribed them to.
748
+ subscriptions: [
749
+ ...phone ? [{ channel: "TEXT", type: "MARKETING" }] : [],
750
+ ...email ? [{ channel: "EMAIL", type: "MARKETING" }] : []
751
+ ],
752
+ user
753
+ },
754
+ token
755
+ });
756
+ return {
757
+ message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
758
+ response: { accepted: true, unsubscribed: true }
759
+ };
760
+ }
761
+ await api("/v1/subscriptions", {
762
+ fetcher,
763
+ method: "POST",
764
+ payload: {
765
+ // LOCALE, because we hold no signUpSourceId and the docs require
766
+ // one or the other. The country is READ OFF the number when there
767
+ // is one — libphonenumber knows it from the calling code — rather
768
+ // than assumed; only the fallback pair below is a default, and it
769
+ // is the one value here that no vendor document dictates.
770
+ //
771
+ // ponytail: en/US default. A `signUpSourceId` field on the
772
+ // connection is the upgrade — Attentive's sign-up sources carry
773
+ // the consent language, which is a better answer than any locale
774
+ // we can infer — and it replaces this branch entirely.
775
+ locale: {
776
+ country: phone && detectCountry(phone) || "US",
777
+ language: "en"
778
+ },
779
+ subscriptionType: "MARKETING",
780
+ user
781
+ },
782
+ token
783
+ });
784
+ const membership = await api("/v2/bulk/segments/members", {
785
+ fetcher,
786
+ method: "POST",
787
+ payload: {
788
+ externalId: segment,
789
+ members: [user]
790
+ },
791
+ token
792
+ });
793
+ return {
794
+ // ACCEPTED, NOT LIVE. Both writes answered 202, which means Attentive
795
+ // queued them — a merchant who reads "synced" and looks for the person
796
+ // in Attentive a second later has been told the wrong thing.
797
+ message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
798
+ response: {
799
+ accepted: true,
800
+ ...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
801
+ }
802
+ };
803
+ }
804
+ },
556
805
  email: false,
557
806
  inbound: false,
558
807
  lifecycle: false,
@@ -565,26 +814,13 @@ var attentive_default2 = {
565
814
  // show a picker quietly missing most of a real account. The response's
566
815
  // only identifier is `externalId`, so an entry without one cannot be
567
816
  // stored and is dropped.
568
- audiences: async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
817
+ audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
569
818
  const query = new URLSearchParams({
570
819
  limit: String(Math.min(limit, 1e3)),
571
820
  ...cursor && { cursor },
572
821
  ...(search == null ? void 0 : search.value) && { name: String(search.value).trim() }
573
822
  });
574
- const response = await fetcher(
575
- "https://api.attentivemobile.com/v2/segments?" + query,
576
- {
577
- headers: { authorization: "Bearer " + token },
578
- signal: AbortSignal.timeout(15e3)
579
- }
580
- );
581
- if (!response.ok) {
582
- throw Object.assign(
583
- new Error("Attentive refused the request (" + response.status + ")"),
584
- { status: response.status }
585
- );
586
- }
587
- const body = await response.json();
823
+ const body = await api("/v2/segments?" + query, { fetcher, token });
588
824
  return {
589
825
  items: ((body == null ? void 0 : body.segments) || []).filter((segment) => segment == null ? void 0 : segment.externalId).map((segment) => ({ id: segment.externalId, title: (segment == null ? void 0 : segment.name) || segment.externalId })),
590
826
  pageInfo: {
@@ -602,33 +838,67 @@ var attentive_default2 = {
602
838
  webhook: false
603
839
  },
604
840
  icon: attentive_default,
841
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
842
+ // what an admin types on the provider screen, and the only declaration of it.
843
+ // It lives beside `requires`, which names the same variables: the manifest
844
+ // says what it needs and this says how someone supplies it, so a credential
845
+ // cannot be required by a vendor that offers nowhere to enter it.
846
+ //
847
+ // `redact` marks a secret — never returned by the api, and blank on save means
848
+ // keep the stored value. `required` drives the live check.
849
+ provider: {
850
+ fields: [
851
+ { input: "text", key: "clientId", credential: "ATTENTIVE_OAUTH_CLIENT_ID", label: "Client ID", required: true },
852
+ { input: "password", key: "clientSecret", credential: "ATTENTIVE_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
853
+ ]
854
+ },
605
855
  requires: [
606
856
  "ATTENTIVE_OAUTH_CLIENT_ID",
607
857
  "ATTENTIVE_OAUTH_CLIENT_SECRET"
608
858
  ],
609
859
  slug: "attentive",
610
- // A consent with no segment chosen is authenticated and inert — the sync,
611
- // when it ships, needs somewhere to put people.
860
+ // A consent with no segment chosen is authenticated and inert — the sync needs
861
+ // somewhere to put people so the card says Pending rather than Active over
862
+ // nothing.
612
863
  status: (data2) => {
613
864
  var _a;
614
865
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? data2.status : "pending";
615
866
  },
616
- // No steps: subscriber sync has not shipped, so this vendor contributes
617
- // nothing to a workflow yet. An empty steps object is the honest declaration.
618
- steps: {},
867
+ steps: {
868
+ contacts: {
869
+ // A DECLARATION, not the work. The nesting IS the name: this is
870
+ // `step.contacts.sync`, the string a workflow document stores. Klaviyo and
871
+ // Mailchimp declare the same type — a step belongs to the capability, not
872
+ // to whoever implements it — and the connection on the step document is
873
+ // what says which vendor runs.
874
+ sync: ({ data: data2 }) => ({
875
+ hook: "contacts.sync",
876
+ // NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo: auth.connect is false
877
+ // because Attentive publishes no account-identity response we can read
878
+ // (see its comment), and settings.segment is an opaque externalId no
879
+ // merchant would recognise in a builder label.
880
+ key: "Sync contact to Attentive",
881
+ queue: "connection",
882
+ // Nothing for a merchant to configure on the step itself — the segment
883
+ // is chosen once on the connection. Declared empty rather than omitted,
884
+ // so "this step takes no settings" and "nobody thought about settings"
885
+ // stay different statements.
886
+ settings: {},
887
+ // BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
888
+ // ever fires for someone with no history yet, and crossing into a
889
+ // segment is the other moment a contact is worth pushing.
890
+ triggers: ["lead.insert", "segment.contact.add"],
891
+ usage: { actions: 1 }
892
+ })
893
+ }
894
+ },
895
+ // WHY, in the merchant's words, and what to do about it.
619
896
  tasks: (data2) => {
620
897
  var _a;
621
- return [
622
- ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
623
- {
624
- message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
625
- title: "Choose a segment"
626
- }
627
- ],
898
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
628
899
  {
629
- message: "Contact syncing to Attentive segments has not shipped yet. Nothing is being sent to Attentive right now.",
630
- title: "Subscriber sync not available yet",
631
- type: "warning"
900
+ message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
901
+ title: "Choose a segment"
632
902
  }
633
903
  ];
634
904
  },
@@ -681,10 +951,11 @@ var request = async ({
681
951
  // lib/hubspot.js
682
952
  var HUBSPOT_BASE = "https://api.hubapi.com";
683
953
  var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
954
+ if (!token) throw new Error("HubSpot access token missing \u2014 pass token (the drawbridge provider's hubspotToken)");
684
955
  return (fetcher || request)({
685
956
  body,
686
957
  headers: {
687
- "Authorization": "Bearer " + (token || process.env.HUBSPOT_ACCESS_TOKEN)
958
+ "Authorization": "Bearer " + token
688
959
  },
689
960
  method,
690
961
  query,
@@ -786,16 +1057,15 @@ var contacts = {
786
1057
  // FORGET A CONTACT, by id or by email. Account deletion — the caller had
787
1058
  // to search then remove, which is one round trip it should not have to
788
1059
  // know about.
789
- remove: async ({ email, fetcher, id, token }) => {
790
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
791
- if (!key) return;
792
- const contact = id || await lookup({ email, fetcher, token: key });
1060
+ remove: async ({ email, id, token }, { fetcher } = {}) => {
1061
+ if (!token) return;
1062
+ const contact = id || await lookup({ email, fetcher, token });
793
1063
  if (!contact) return;
794
1064
  return hubspotRequest({
795
1065
  fetcher,
796
1066
  method: "DELETE",
797
1067
  path: "/crm/v3/objects/contacts/" + contact,
798
- token: key
1068
+ token
799
1069
  });
800
1070
  },
801
1071
  // Connect an account to its contact by email, creating it if absent, and
@@ -804,24 +1074,23 @@ var contacts = {
804
1074
  // no delete-old-then-create-new.
805
1075
  //
806
1076
  // Prefer the cached hubspotId; fall back to a search; create last.
807
- sync: async ({ doc, fetcher, token }) => {
1077
+ sync: async ({ doc, token }, { fetcher } = {}) => {
808
1078
  var _a, _b;
809
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
810
- if (!key) return;
1079
+ if (!token) return;
811
1080
  if (doc == null ? void 0 : doc.hubspotId) {
812
1081
  try {
813
- return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token: key })) == null ? void 0 : _a.id;
1082
+ return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token })) == null ? void 0 : _a.id;
814
1083
  } catch (error) {
815
1084
  if ((error == null ? void 0 : error.status) !== 404) throw error;
816
1085
  }
817
1086
  }
818
- const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token: key });
1087
+ const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token });
819
1088
  return (_b = await send({
820
1089
  doc,
821
1090
  fetcher,
822
1091
  method: existing ? "PATCH" : "POST",
823
1092
  path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
824
- token: key
1093
+ token
825
1094
  })) == null ? void 0 : _b.id;
826
1095
  }
827
1096
  };
@@ -831,7 +1100,7 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
831
1100
  <rect width="500" height="500" fill="#BAEC5F"/>
832
1101
  <g clip-path="url(#clip0_2115_2832)">
833
1102
  <path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
834
- <path d="M360.001 127.523L323.693 108.282L284.948 178.498V321.596L322.923 391.749L359.393 372.79L326.224 311.52V188.73L360.001 127.523Z" fill="#0D1314"/>
1103
+ <path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
835
1104
  </g>
836
1105
  <defs>
837
1106
  <clipPath id="clip0_2115_2832">
@@ -1277,6 +1546,36 @@ var plans = {
1277
1546
  conversion: 0.5
1278
1547
  }
1279
1548
  };
1549
+ var resolvePlan = (subscription) => {
1550
+ var _a, _b;
1551
+ const custom = subscription == null ? void 0 : subscription.custom;
1552
+ if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
1553
+ return {
1554
+ // Reusing all.features / all.limits is what keeps a custom plan the same
1555
+ // SHAPE as a catalog one: the base feature grants every plan carries, the
1556
+ // campaign limits that are always infinite, and members defaulting to
1557
+ // infinite when a deal does not name it.
1558
+ conversion: custom.conversion ?? free.conversion,
1559
+ custom: true,
1560
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
1561
+ // baseline whether or not the deal thought to name it. Today that is the
1562
+ // sending domain: every catalog paid tier grants it, and a custom plan
1563
+ // silently lacking it would be a support ticket, not a pricing decision.
1564
+ features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
1565
+ limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
1566
+ // A custom plan stores its overage BARE on `custom.overages` — a different
1567
+ // shape from the catalog's nested one. Number() so a deal stored as a string
1568
+ // still resolves to cents-per-action; an unnamed overage stays undefined
1569
+ // (it bills nothing) rather than becoming NaN.
1570
+ actionCents: custom.overages == null ? void 0 : Number(custom.overages),
1571
+ overages: { actions: custom.overages },
1572
+ title: custom.title || "Custom"
1573
+ };
1574
+ };
1575
+ var conversionRate = (subscription) => {
1576
+ var _a;
1577
+ return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
1578
+ };
1280
1579
 
1281
1580
  // lib/transactions.js
1282
1581
  var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
@@ -1431,7 +1730,36 @@ var channels = {
1431
1730
  }
1432
1731
  };
1433
1732
 
1434
- // lib/connections/drawbridge.js
1733
+ // lib/connections/providers/drawbridge.js
1734
+ var interpolate = (template, data2) => {
1735
+ if (!template) return template;
1736
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
1737
+ };
1738
+ var teamRecipients = async ({ memberIds = [], organization: organization2, read }) => {
1739
+ const org = await read.get({ collection: "organization", query: { id: organization2 } });
1740
+ const owner = (org == null ? void 0 : org.owner) ? await read.get({ collection: "user", query: { id: org.owner } }) : null;
1741
+ const members = memberIds.length ? await read.aggregate({
1742
+ collection: "member",
1743
+ pipeline: [
1744
+ {
1745
+ $match: {
1746
+ id: { $in: memberIds },
1747
+ organization: organization2,
1748
+ status: "accepted"
1749
+ }
1750
+ }
1751
+ ]
1752
+ }) : [];
1753
+ const seen = /* @__PURE__ */ new Set();
1754
+ return [owner, ...members].filter((member) => {
1755
+ if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1756
+ const address = member.email.toLowerCase();
1757
+ if (seen.has(address)) return false;
1758
+ seen.add(address);
1759
+ return true;
1760
+ });
1761
+ };
1762
+ var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
1435
1763
  var drawbridge_default2 = {
1436
1764
  auth: {
1437
1765
  type: "none"
@@ -1449,10 +1777,15 @@ var drawbridge_default2 = {
1449
1777
  exclusive: false,
1450
1778
  fields: [],
1451
1779
  group: "developer",
1452
- // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
1453
- // suppression collection and the queues; segment sync needs the streams. A
1454
- // published package carrying those makes every consumer carry them, which is
1455
- // the reason `{}` exists as an answer.
1780
+ // THE BODIES LIVE HERE, beside the declarations that name them. They used to
1781
+ // live in drawbridge-sync because they touch the database, the queues and the
1782
+ // sockets and a published package cannot carry a controller.
1783
+ //
1784
+ // It does not have to. A hook is a function, so everything it needs is PASSED
1785
+ // IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
1786
+ // for the one write whose RESULT the hook has to count. Everything else a hook
1787
+ // wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
1788
+ // performs it. See lib/connections/contract.js for that shape.
1456
1789
  hooks: {
1457
1790
  auth: {
1458
1791
  // Nothing to connect, revoke, probe or re-scope.
@@ -1473,13 +1806,157 @@ var drawbridge_default2 = {
1473
1806
  // accounts DRAWBRIDGE holds rather than ones a merchant connects.
1474
1807
  contacts,
1475
1808
  email: {
1476
- digest: {},
1477
- // To organization members. NEVER suppressed and never billed: an
1478
- // entrant's opt-out must not silence an alert to staff, and staff mail is
1479
- // not a metered send.
1480
- notify: {},
1481
- // To a lead. Suppression applies and the send is billed.
1482
- send: {}
1809
+ // A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
1810
+ // lead.
1811
+ //
1812
+ // The count is the point: `email.notify` tells the owner one lead arrived
1813
+ // and dampens a spike to one message per bucket, which is deliberately not
1814
+ // a count. This is where "you got 43 entries this week" comes from.
1815
+ digest: async ({ context, step, workflow }, { read } = {}) => {
1816
+ var _a, _b, _c, _d;
1817
+ const days = { day: 1, month: 30, week: 7 }[(_a = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _a.event] || 7;
1818
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
1819
+ const campaign = ((_c = (_b = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _b.filters) == null ? void 0 : _c.campaign) || null;
1820
+ const [counted] = await read.aggregate({
1821
+ collection: "lead",
1822
+ pipeline: [
1823
+ {
1824
+ $match: {
1825
+ createdAt: { $gte: since },
1826
+ organization: workflow.organization,
1827
+ ...campaign && { campaigns: { $in: [campaign] } }
1828
+ }
1829
+ },
1830
+ { $count: "count" }
1831
+ ]
1832
+ });
1833
+ const count = Number((counted == null ? void 0 : counted.count) || 0);
1834
+ const request2 = { campaign, count, days };
1835
+ if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
1836
+ const recipients = await teamRecipients({
1837
+ memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
1838
+ organization: workflow.organization,
1839
+ read
1840
+ });
1841
+ const values = { ...context, count };
1842
+ return {
1843
+ message: "Digest of " + count + " new lead(s) queued for " + recipients.length + " recipient(s).",
1844
+ request: request2,
1845
+ response: { count, notified: recipients.length },
1846
+ writes: recipients.map((member) => {
1847
+ var _a2, _b2;
1848
+ return queueNotification({
1849
+ audience: "member",
1850
+ message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
1851
+ organization: workflow.organization,
1852
+ send: { type: "email", email: member.email },
1853
+ title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
1854
+ workflow: workflow.id
1855
+ });
1856
+ })
1857
+ };
1858
+ },
1859
+ // To the organization's OWN PEOPLE. Never suppressed, never
1860
+ // subscription-gated, no unsubscribe footer — telling an org's staff about
1861
+ // their own leads is not commercial mail to a stranger.
1862
+ //
1863
+ // FREE, permanently. The lead that triggered this run already consumed the
1864
+ // billable action, and `members` is a list — billing here would turn one
1865
+ // lead into five more charges and the org would be paying to read its own
1866
+ // mail. The declaration prices it at zero; the shell bills nothing for
1867
+ // zero.
1868
+ notify: async ({ context, step, workflow }, { read } = {}) => {
1869
+ var _a;
1870
+ const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
1871
+ const request2 = { members: memberIds.length };
1872
+ const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
1873
+ if (!recipients.length) {
1874
+ return {
1875
+ message: "No owner or accepted member with an email address \u2014 team notification skipped.",
1876
+ request: request2,
1877
+ response: { skipped: true },
1878
+ skipped: true
1879
+ };
1880
+ }
1881
+ const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
1882
+ return {
1883
+ message: "Team notification queued for " + recipients.length + " recipient(s).",
1884
+ request: request2,
1885
+ response: { notified: recipients.length },
1886
+ writes: recipients.map((member) => {
1887
+ var _a2, _b;
1888
+ return {
1889
+ ...queueNotification({
1890
+ audience: "member",
1891
+ // Per workflow, recipient AND bucket, so one recipient's damper
1892
+ // can never swallow another's mail and a later bucket is never
1893
+ // mistaken for a duplicate of an earlier one.
1894
+ key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1895
+ message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
1896
+ organization: workflow.organization,
1897
+ send: { type: "email", email: member.email },
1898
+ title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1899
+ workflow: workflow.id
1900
+ }),
1901
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1902
+ // told within the bucket. Declared per write rather than assumed by
1903
+ // the shell, because on every other write here a duplicate key is a
1904
+ // real failure.
1905
+ ignoreDuplicate: true
1906
+ };
1907
+ })
1908
+ };
1909
+ },
1910
+ // Drawbridge sends lead-facing email itself — no merchant provider gates
1911
+ // it.
1912
+ //
1913
+ // This QUEUES rather than sends: queue/notification.js owns delivery, the
1914
+ // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
1915
+ // and what, correctly, and to refuse early when it must not send at all.
1916
+ send: async ({ context, step, workflow }, { canSend, read } = {}) => {
1917
+ var _a, _b;
1918
+ const to = context == null ? void 0 : context.email;
1919
+ if (!to) throw new Error("No email address on context (context.email is required)");
1920
+ const request2 = { to };
1921
+ const { ok: sendable } = await canSend({ channel: "email", to });
1922
+ if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
1923
+ const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
1924
+ const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
1925
+ if ((subscription == null ? void 0 : subscription.status) !== "active") {
1926
+ return {
1927
+ message: "Organization has no active subscription \u2014 workflow-step email skipped.",
1928
+ request: request2,
1929
+ response: { skipped: true },
1930
+ skipped: true
1931
+ };
1932
+ }
1933
+ return {
1934
+ message: "Email queued for delivery to " + to + ".",
1935
+ request: request2,
1936
+ response: { queued: true },
1937
+ // NO `connection` FIELD, deliberately: the platform sends this.
1938
+ // `audience : 'lead'` states what the queue would otherwise infer from
1939
+ // shape.
1940
+ //
1941
+ // `campaign` is not decoration. queue/notification.js mints the
1942
+ // unsubscribe token with it, so it decides whether opting out is
1943
+ // scoped to this campaign or the whole organization, and it names the
1944
+ // campaign in the footer. Sending without it silently broadens every
1945
+ // opt-out to the entire organization.
1946
+ writes: [
1947
+ queueNotification({
1948
+ audience: "lead",
1949
+ campaign: (context == null ? void 0 : context.campaign) || null,
1950
+ lead: (context == null ? void 0 : context.lead) || null,
1951
+ message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
1952
+ organization: workflow.organization,
1953
+ send: { type: "email", email: to },
1954
+ title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1955
+ workflow: workflow.id
1956
+ })
1957
+ ]
1958
+ };
1959
+ }
1483
1960
  },
1484
1961
  inbound: false,
1485
1962
  lifecycle: false,
@@ -1489,13 +1966,207 @@ var drawbridge_default2 = {
1489
1966
  products: false,
1490
1967
  promotions: false
1491
1968
  },
1492
- segment: { sync: {} },
1493
- sms: { send: {} },
1969
+ segment: {
1970
+ // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
1971
+ // contact in an organization against every segment, which is too much for
1972
+ // one job, so it returns chunks and the shell defers completion.
1973
+ //
1974
+ // Returning `chunks` is the only thing that makes it different. The
1975
+ // declaration, the guards, the step document and the price are the shell's,
1976
+ // exactly as they are for a step that finishes in one go.
1977
+ sync: async ({ context, step }, { chunkSize, logger: logger2, read, resolveContact } = {}) => {
1978
+ var _a, _b, _c;
1979
+ if (!chunkSize) throw new Error("segment.sync needs chunkSize from the shell");
1980
+ const organization2 = context == null ? void 0 : context.organization;
1981
+ const configured = (_a = step == null ? void 0 : step.settings) == null ? void 0 : _a.segment;
1982
+ const request2 = { organization: organization2 || null, segmentId: configured || null };
1983
+ const release = (ids, status = "active") => {
1984
+ const released = (ids || []).filter(Boolean);
1985
+ return {
1986
+ events: organization2 ? released.map((id) => ({
1987
+ event: "organization.segments",
1988
+ payload: { id, status },
1989
+ room: "organization." + organization2
1990
+ })) : [],
1991
+ writes: released.map((id) => ({
1992
+ collection: "segment",
1993
+ data: { $set: { status } },
1994
+ operation: "update",
1995
+ query: { id }
1996
+ }))
1997
+ };
1998
+ };
1999
+ if (!organization2) {
2000
+ return {
2001
+ ...release([configured]),
2002
+ message: "Trigger data missing organization id \u2014 cannot sync segments.",
2003
+ request: request2,
2004
+ response: { skipped: true },
2005
+ skipped: true
2006
+ };
2007
+ }
2008
+ const segments = await read.aggregate({
2009
+ collection: "segment",
2010
+ pipeline: [{ $match: configured ? { id: configured, organization: organization2 } : { organization: organization2 } }]
2011
+ });
2012
+ if (!segments.length) {
2013
+ return {
2014
+ ...release([configured]),
2015
+ message: "No segments matched the request \u2014 nothing to sync.",
2016
+ request: request2,
2017
+ response: { skipped: true },
2018
+ skipped: true
2019
+ };
2020
+ }
2021
+ const segmentIds = segments.map((entry) => entry.id);
2022
+ try {
2023
+ let backfilled = 0;
2024
+ if (segments.some((entry) => entry.system)) {
2025
+ const contacted = await read.aggregate({
2026
+ collection: "contact",
2027
+ pipeline: [
2028
+ { $match: { organization: organization2 } },
2029
+ { $project: { _id: 0, leads: 1 } },
2030
+ { $unwind: "$leads" },
2031
+ { $group: { _id: null, ids: { $addToSet: "$leads" } } }
2032
+ ]
2033
+ });
2034
+ const uncontacted = await read.aggregate({
2035
+ collection: "lead",
2036
+ pipeline: [
2037
+ { $match: { id: { $nin: ((_b = contacted[0]) == null ? void 0 : _b.ids) || [] }, organization: organization2 } },
2038
+ { $project: { _id: 0, id: 1 } }
2039
+ ]
2040
+ });
2041
+ for (const lead of uncontacted) {
2042
+ try {
2043
+ await resolveContact({ leadId: lead.id });
2044
+ backfilled += 1;
2045
+ } catch (error) {
2046
+ if (error.code !== 11e3) throw error;
2047
+ }
2048
+ }
2049
+ (_c = logger2 == null ? void 0 : logger2.info) == null ? void 0 : _c.call(logger2, "segment.sync.backfill", { backfilled, organization: organization2, uncontacted: uncontacted.length });
2050
+ }
2051
+ const contacts2 = await read.aggregate({
2052
+ collection: "contact",
2053
+ pipeline: [
2054
+ { $match: { organization: organization2 } },
2055
+ { $project: { _id: 0, id: 1 } },
2056
+ { $sort: { id: 1 } }
2057
+ ]
2058
+ });
2059
+ if (!contacts2.length) {
2060
+ return {
2061
+ ...release(segmentIds),
2062
+ message: "Organization has no contacts to evaluate against segments.",
2063
+ request: request2,
2064
+ response: { skipped: true },
2065
+ skipped: true
2066
+ };
2067
+ }
2068
+ const contactIds = contacts2.map((contact) => contact.id);
2069
+ const org = await read.get({ collection: "organization", query: { id: organization2 } });
2070
+ const chunks = [];
2071
+ for (let index = 0; index < contactIds.length; index += chunkSize) {
2072
+ chunks.push({
2073
+ contactIds: contactIds.slice(index, index + chunkSize),
2074
+ organization: organization2,
2075
+ segments: segmentIds,
2076
+ // A BACKFILL IS NOT BILLABLE. It creates the contacts this run
2077
+ // then evaluates, so charging for it would bill an organization
2078
+ // for work its own history made necessary.
2079
+ usage: (context == null ? void 0 : context.billable) === true && backfilled === 0 ? (org == null ? void 0 : org.usage) || null : null
2080
+ });
2081
+ }
2082
+ return {
2083
+ chunks,
2084
+ ...configured && { extra: { segment: configured } },
2085
+ message: "Queued " + contactIds.length + " contacts across " + chunks.length + " chunks for segment evaluation.",
2086
+ queue: "segment",
2087
+ request: { ...request2, segments: segmentIds },
2088
+ response: { chunks: chunks.length, contacts: contactIds.length, segments: segments.length }
2089
+ };
2090
+ } catch (error) {
2091
+ throw Object.assign(error, release(segmentIds, "error"));
2092
+ }
2093
+ }
2094
+ },
2095
+ sms: {
2096
+ // SMS TO A LEAD, through the merchant's own Twilio connection.
2097
+ //
2098
+ // WITHDRAWN from the builder — twilio went, and a connection-gated step
2099
+ // with no connection to gate on could only ever render permanently
2100
+ // disabled. Stored workflows still carry it, so it still runs.
2101
+ //
2102
+ // It looks its own connection up rather than relying on the shell, because
2103
+ // the step is declared by the PRIVATE drawbridge connection (which has
2104
+ // none) while the credential belongs to twilio (which has no manifest).
2105
+ // Platform SMS will remove that split the way it did for email.
2106
+ send: async ({ context, step, workflow }, { canSend, read } = {}) => {
2107
+ var _a, _b, _c;
2108
+ const to = (_a = context == null ? void 0 : context.phone) == null ? void 0 : _a.number;
2109
+ if (!to) throw new Error("No phone number on context (context.phone.number is required)");
2110
+ const request2 = { to };
2111
+ const connection2 = await read.get({
2112
+ collection: "connection",
2113
+ query: { organization: workflow.organization, slug: "twilio", status: "active" }
2114
+ });
2115
+ if (!connection2) return { message: "No active Twilio SMS connection \u2014 workflow-step SMS skipped.", request: request2, response: { skipped: true }, skipped: true };
2116
+ const { ok: sendable } = await canSend({ channel: "sms", to: context.phone });
2117
+ if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
2118
+ return {
2119
+ message: "SMS queued for delivery to " + to + " via twilio.",
2120
+ request: request2,
2121
+ response: { provider: "twilio", queued: true },
2122
+ // QUEUES rather than sends: queue/notification.js owns delivery, the
2123
+ // carrier opt-out line and the segment count this is billed on.
2124
+ writes: [
2125
+ queueNotification({
2126
+ connection: connection2.id,
2127
+ message: interpolate((_b = step.settings) == null ? void 0 : _b.message, context),
2128
+ organization: workflow.organization,
2129
+ send: { phone: { number: to }, type: "phone" },
2130
+ title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
2131
+ workflow: workflow.id
2132
+ })
2133
+ ]
2134
+ };
2135
+ }
2136
+ },
1494
2137
  webhook: false
1495
2138
  },
1496
2139
  icon: drawbridge_default,
1497
2140
  // PRIVATE: never in the catalog, always available to the builder.
1498
2141
  private: true,
2142
+ // THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
2143
+ // HubSpot portal. No merchant ever sees these; they are what an admin types on
2144
+ // the provider screen so that Drawbridge itself can send.
2145
+ //
2146
+ // They belong on THIS manifest because this is the connection that sends: the
2147
+ // email, sms and segment hooks below are the only things that spend them, and
2148
+ // a private connection is still where a vendor fact lives.
2149
+ //
2150
+ // UNLIKE every public vendor, none of these appear in `requires` — see the
2151
+ // comment there. Availability and configuration are different questions, and a
2152
+ // missing CRM token must not take every base workflow step away.
2153
+ provider: {
2154
+ fields: [
2155
+ { input: "email", key: "accountSender", credential: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
2156
+ { input: "password", key: "apiKey", credential: "SENDGRID_API_KEY", label: "SendGrid API key", redact: true, required: true },
2157
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
2158
+ // without a token — requiring it would make the whole drawbridge provider
2159
+ // read not-live over something no merchant ever sees.
2160
+ { input: "password", key: "hubspotToken", credential: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true },
2161
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2162
+ // either. Unset, it degrades to the account sender rather than
2163
+ // refusing to start.
2164
+ { input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." },
2165
+ { input: "text", key: "smsFrom", credential: "TWILIO_ACCOUNT_FROM", label: "SMS number", required: true },
2166
+ { input: "password", key: "smsSid", credential: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", redact: true, required: true },
2167
+ { input: "password", key: "smsToken", credential: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", redact: true, required: true }
2168
+ ]
2169
+ },
1499
2170
  // NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
1500
2171
  //
1501
2172
  // `requires` gates AVAILABILITY: a name in it that is unset removes the whole
@@ -1532,11 +2203,11 @@ var drawbridge_default2 = {
1532
2203
  key: "Email \u2014 Digest",
1533
2204
  queue: "notification",
1534
2205
  settings: {
1535
- // The organization OWNER is always a recipient, resolved in sync,
1536
- // so this is additional recipients rather than the list. It cannot
1537
- // be required: the members endpoint is owner-gated and the owner is
1538
- // not a member document, so a solo merchant has nothing to pick and
1539
- // could never save the step.
2206
+ // The organization OWNER is always a recipient, resolved by the
2207
+ // hook, so this is additional recipients rather than the list. It
2208
+ // cannot be required: the members endpoint is owner-gated and the
2209
+ // owner is not a member document, so a solo merchant has nothing to
2210
+ // pick and could never save the step.
1540
2211
  members: { of: "string", type: "array" },
1541
2212
  message: { required: true, type: "string" },
1542
2213
  subject: { required: true, type: "string" }
@@ -1632,12 +2303,12 @@ var drawbridge_default2 = {
1632
2303
 
1633
2304
  // lib/connections/icons/klaviyo.js
1634
2305
  var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1635
- <rect width="500" height="500" fill="white"/>
2306
+ <rect width="500" height="500" fill="#FF4B32"/>
1636
2307
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
1637
2308
  </svg>`;
1638
2309
 
1639
- // lib/connections/klaviyo.js
1640
- var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
2310
+ // lib/connections/providers/klaviyo.js
2311
+ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
1641
2312
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
1642
2313
  ...payload && { body: JSON.stringify(payload) },
1643
2314
  headers: {
@@ -1673,7 +2344,8 @@ var klaviyo_default2 = {
1673
2344
  // differently, and it says so in hooks.auth.token rather than as a flag here.
1674
2345
  auth: {
1675
2346
  oauth: {
1676
- // NAMES the env vars holding OUR application's client. One identity,
2347
+ // NAMES the credentials holding OUR application's client keys into
2348
+ // the stored provider credentials, not env vars. One identity,
1677
2349
  // every merchant — the token is the merchant's and arrives from their
1678
2350
  // own consent, which is what stops one organization reading another's
1679
2351
  // data.
@@ -1813,9 +2485,9 @@ var klaviyo_default2 = {
1813
2485
  // renders an empty "Klaviyo account" field, because the merchant is
1814
2486
  // never asked which account they connected — the consent already
1815
2487
  // decided it, and asking again would be a question we can answer.
1816
- connect: async ({ fetcher, tokens }) => {
2488
+ connect: async ({ tokens }, { fetcher } = {}) => {
1817
2489
  var _a, _b, _c;
1818
- const body = await api("/accounts", { fetcher, token: tokens.accessToken });
2490
+ const body = await api2("/accounts", { fetcher, token: tokens.accessToken });
1819
2491
  const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
1820
2492
  return {
1821
2493
  account: ((_c = (_b = account == null ? void 0 : account.attributes) == null ? void 0 : _b.contact_information) == null ? void 0 : _c.organization_name) || (account == null ? void 0 : account.id) || null,
@@ -1828,7 +2500,7 @@ var klaviyo_default2 = {
1828
2500
  //
1829
2501
  // Basic auth with our client, exactly like the token exchange — the
1830
2502
  // token being revoked is the subject, not the credential.
1831
- disconnect: async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
2503
+ disconnect: async ({ clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {}) => {
1832
2504
  const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
1833
2505
  if (!token) return { revoked: false };
1834
2506
  const response = await fetcher(manifest.auth.oauth.urls.revoke, {
@@ -1851,7 +2523,7 @@ var klaviyo_default2 = {
1851
2523
  // the refresh token is the only thing that asks Klaviyo.
1852
2524
  //
1853
2525
  // It also keeps the grant warm against the 90-day idle window above.
1854
- probe: async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
2526
+ probe: async ({ clientId, clientSecret, manifest, settings }, { fetcher } = {}) => {
1855
2527
  const token = await accessToken({
1856
2528
  clientId,
1857
2529
  clientSecret,
@@ -1881,19 +2553,18 @@ var klaviyo_default2 = {
1881
2553
  // the store's.
1882
2554
  commerce: false,
1883
2555
  // The verb the contacts.sync step points at. It does the work — including
1884
- // writing the profile id back onto the lead — and returns what happened.
1885
2556
  contacts: {
1886
2557
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1887
2558
  // different thing from deleting the profile.
1888
2559
  remove: false,
1889
- sync: async ({ contact, fetcher, lead, settings, suppressed, token }) => {
2560
+ sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
1890
2561
  var _a, _b, _c;
1891
2562
  const list = settings == null ? void 0 : settings.list;
1892
2563
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
1893
2564
  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);
1894
2565
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
1895
2566
  const totals = (contact == null ? void 0 : contact.totals) || {};
1896
- const profile = await api("/profiles/", {
2567
+ const profile = await api2("/profiles/", {
1897
2568
  fetcher,
1898
2569
  method: "POST",
1899
2570
  payload: {
@@ -1921,7 +2592,7 @@ var klaviyo_default2 = {
1921
2592
  });
1922
2593
  const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
1923
2594
  if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
1924
- await api("/profile-subscription-bulk-create-jobs/", {
2595
+ await api2("/profile-subscription-bulk-create-jobs/", {
1925
2596
  fetcher,
1926
2597
  method: "POST",
1927
2598
  payload: {
@@ -1978,13 +2649,13 @@ var klaviyo_default2 = {
1978
2649
  // it, so one call quietly returns the first ten lists and an account
1979
2650
  // with more shows a picker missing the one they wanted, with nothing to
1980
2651
  // indicate anything was cut.
1981
- audiences: async ({ cursor, fetcher, limit = 100, search, token }) => {
2652
+ audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
1982
2653
  var _a, _b;
1983
2654
  const audiences = [];
1984
2655
  let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
1985
2656
  let pages = 0;
1986
2657
  while (next && audiences.length < limit && pages < 20) {
1987
- const body = await api(next, { fetcher, token });
2658
+ const body = await api2(next, { fetcher, token });
1988
2659
  for (const list of (body == null ? void 0 : body.data) || []) {
1989
2660
  audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
1990
2661
  }
@@ -2010,6 +2681,16 @@ var klaviyo_default2 = {
2010
2681
  webhook: false
2011
2682
  },
2012
2683
  icon: klaviyo_default,
2684
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
2685
+ // what an admin types on the provider screen. Declared here rather than in a
2686
+ // table in lib/providers.js, so a vendor's credentials sit beside the
2687
+ // `requires` that names the same variables.
2688
+ provider: {
2689
+ fields: [
2690
+ { input: "text", key: "clientId", credential: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", required: true },
2691
+ { input: "password", key: "clientSecret", credential: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
2692
+ ]
2693
+ },
2013
2694
  requires: [
2014
2695
  "KLAVIYO_OAUTH_CLIENT_ID",
2015
2696
  "KLAVIYO_OAUTH_CLIENT_SECRET"
@@ -2083,6 +2764,9 @@ var klaviyo_default2 = {
2083
2764
  title: "Klaviyo"
2084
2765
  };
2085
2766
 
2767
+ // lib/connections/providers/mailchimp.js
2768
+ var import_node_crypto2 = require("crypto");
2769
+
2086
2770
  // lib/connections/icons/mailchimp.js
2087
2771
  var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2088
2772
  <rect width="500" height="500" fill="#FFE01B"/>
@@ -2090,11 +2774,30 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
2090
2774
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
2091
2775
  </svg>`;
2092
2776
 
2093
- // lib/connections/mailchimp.js
2777
+ // lib/connections/providers/mailchimp.js
2094
2778
  var base = (dc) => {
2095
2779
  if (!dc) throw new Error("This Mailchimp connection has no data centre stored, so there is no host to call");
2096
2780
  return "https://" + dc + ".api.mailchimp.com/3.0";
2097
2781
  };
2782
+ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token }) => {
2783
+ const response = await fetcher(base(dc) + path, {
2784
+ ...payload && { body: JSON.stringify(payload) },
2785
+ headers: {
2786
+ authorization: "Bearer " + token,
2787
+ ...payload && { "content-type": "application/json" }
2788
+ },
2789
+ method,
2790
+ signal: AbortSignal.timeout(15e3)
2791
+ });
2792
+ if (!response.ok) {
2793
+ throw Object.assign(
2794
+ new Error("Mailchimp refused the request (" + response.status + ")"),
2795
+ { status: response.status }
2796
+ );
2797
+ }
2798
+ return response.json();
2799
+ };
2800
+ var subscriberHash = (email) => (0, import_node_crypto2.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
2098
2801
  var mailchimp_default2 = {
2099
2802
  // OAUTH 2, authorization code. Every url below is quoted from
2100
2803
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -2137,7 +2840,9 @@ var mailchimp_default2 = {
2137
2840
  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.",
2138
2841
  description: [
2139
2842
  "Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Networking in your organization settings puts your own brand in the from line.",
2140
- "This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so connecting today does nothing except choose the audience it will use when it ships."
2843
+ "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.",
2844
+ "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.",
2845
+ "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
2141
2846
  ],
2142
2847
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
2143
2848
  guide: [
@@ -2179,7 +2884,7 @@ var mailchimp_default2 = {
2179
2884
  // The header here is `OAuth <token>`, not Bearer — that is specific to
2180
2885
  // the metadata endpoint. Marketing API calls take Bearer; see the
2181
2886
  // audiences hook.
2182
- connect: async ({ fetcher = fetch, tokens }) => {
2887
+ connect: async ({ tokens }, { fetcher = fetch } = {}) => {
2183
2888
  const response = await fetcher("https://login.mailchimp.com/oauth2/metadata", {
2184
2889
  headers: {
2185
2890
  authorization: "OAuth " + (tokens == null ? void 0 : tokens.accessToken)
@@ -2212,7 +2917,51 @@ var mailchimp_default2 = {
2212
2917
  token: authToken
2213
2918
  },
2214
2919
  commerce: false,
2215
- contacts: { remove: false, sync: false },
2920
+ // The verb the contacts.sync step points at.
2921
+ contacts: {
2922
+ // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2923
+ // different thing from deleting the member — and Mailchimp's own delete is
2924
+ // permanent, so the address can never be re-added.
2925
+ remove: false,
2926
+ // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
2927
+ // why there is no create-or-update branch here. Quoted from Mailchimp's
2928
+ // Marketing API reference for the list-members resource.
2929
+ sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
2930
+ var _a, _b;
2931
+ const audience = settings == null ? void 0 : settings.audience;
2932
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
2933
+ 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);
2934
+ if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2935
+ const hash = subscriberHash(email);
2936
+ const member = await api3("/lists/" + audience + "/members/" + hash, {
2937
+ dc: settings == null ? void 0 : settings.dc,
2938
+ fetcher,
2939
+ method: "PUT",
2940
+ payload: {
2941
+ email_address: email,
2942
+ // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
2943
+ // schemaless — a merge tag that does not exist on the audience is
2944
+ // refused, taking the whole request with it — and FNAME is one of
2945
+ // the two tags every audience is created with. The Drawbridge
2946
+ // totals Klaviyo receives cannot travel until something registers
2947
+ // merge fields on the chosen audience, which is lifecycle.register's
2948
+ // job and is not built.
2949
+ ...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
2950
+ ...suppressed && { status: "unsubscribed" },
2951
+ status_if_new: suppressed ? "unsubscribed" : "subscribed"
2952
+ },
2953
+ token
2954
+ });
2955
+ return {
2956
+ // Merged into `context` for later steps in this run.
2957
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
2958
+ message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
2959
+ // Recorded on the run for support to read back, not a write
2960
+ // instruction — the hook has already written what it needed to.
2961
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
2962
+ };
2963
+ }
2964
+ },
2216
2965
  // Drawbridge sends its own notification email and SMS, and owns its own
2217
2966
  // segments — see the private `drawbridge` manifest. A vendor answering
2218
2967
  // these would be a second sender, which is the arrangement the platform
@@ -2231,24 +2980,13 @@ var mailchimp_default2 = {
2231
2980
  // successful — the same silent truncation Klaviyo has, at a different
2232
2981
  // number. Paged against total_items so an account past a thousand still
2233
2982
  // resolves.
2234
- audiences: async ({ cursor, fetcher = fetch, limit = 100, search, settings, token }) => {
2235
- const dc = settings == null ? void 0 : settings.dc;
2983
+ audiences: async ({ cursor, limit = 100, search, settings, token }, { fetcher } = {}) => {
2236
2984
  const count = Math.min(limit, 1e3);
2237
2985
  const offset = Number(cursor || 0);
2238
- const response = await fetcher(
2239
- base(dc) + "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
2240
- {
2241
- headers: { authorization: "Bearer " + token },
2242
- signal: AbortSignal.timeout(15e3)
2243
- }
2986
+ const body = await api3(
2987
+ "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
2988
+ { dc: settings == null ? void 0 : settings.dc, fetcher, token }
2244
2989
  );
2245
- if (!response.ok) {
2246
- throw Object.assign(
2247
- new Error("Mailchimp refused the request (" + response.status + ")"),
2248
- { status: response.status }
2249
- );
2250
- }
2251
- const body = await response.json();
2252
2990
  const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
2253
2991
  const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
2254
2992
  const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
@@ -2270,6 +3008,15 @@ var mailchimp_default2 = {
2270
3008
  webhook: false
2271
3009
  },
2272
3010
  icon: mailchimp_default,
3011
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
3012
+ // what an admin types on the provider screen, beside the `requires` naming the
3013
+ // same variables.
3014
+ provider: {
3015
+ fields: [
3016
+ { input: "text", key: "clientId", credential: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", required: true },
3017
+ { input: "password", key: "clientSecret", credential: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
3018
+ ]
3019
+ },
2273
3020
  // The OAuth client this deployment registered. Without both, the vendor drops
2274
3021
  // out of availableConnections rather than offering a Connect button that
2275
3022
  // cannot complete.
@@ -2278,64 +3025,145 @@ var mailchimp_default2 = {
2278
3025
  "MAILCHIMP_OAUTH_CLIENT_SECRET"
2279
3026
  ],
2280
3027
  slug: "mailchimp",
2281
- // A key with no audience chosen is authenticated and inert. Mailchimp also
2282
- // needs its merge fields created on that audience before any Drawbridge total
2283
- // can be written to a member unlike Klaviyo, its custom fields are not
2284
- // schemaless so the audience must be picked before lifecycle.register has
2285
- // anything to register against.
3028
+ // A grant with no audience chosen is authenticated and useless the sync has
3029
+ // nowhere to put anyone so the card must say Pending rather than Active over
3030
+ // nothing. Mailchimp also needs its merge fields created on that audience
3031
+ // before any Drawbridge total can be written to a member — unlike Klaviyo, its
3032
+ // custom fields are not schemaless — so the audience must be picked before
3033
+ // lifecycle.register has anything to register against.
2286
3034
  status: (data2) => {
2287
3035
  var _a;
2288
3036
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? data2.status : "pending";
2289
3037
  },
2290
- // No steps: audience sync has not shipped, so this vendor contributes nothing
2291
- // to a workflow yet. An empty steps object is the honest declaration — the
2292
- // catalog renders the connection, and no builder offers a step it cannot run.
2293
- steps: {},
3038
+ steps: {
3039
+ contacts: {
3040
+ // A DECLARATION, not the work. It names the hook that does the work, and
3041
+ // the nesting IS the name: this is `step.contacts.sync`, the string a
3042
+ // workflow document stores. Klaviyo and Attentive declare the same type —
3043
+ // a step belongs to the capability, not to whoever implements it — and the
3044
+ // connection on the step document is what says which vendor runs.
3045
+ sync: ({ data: data2 }) => ({
3046
+ hook: "contacts.sync",
3047
+ // NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo. Mailchimp's
3048
+ // auth.connect deliberately stores only the data centre (a test pins
3049
+ // that), and settings.audience is an opaque list id no merchant would
3050
+ // recognise in a builder label — so the label names the vendor rather
3051
+ // than showing a string like a1b2c3d4e5.
3052
+ key: "Sync contact to Mailchimp",
3053
+ queue: "connection",
3054
+ // Nothing for a merchant to configure on the step itself — the audience
3055
+ // is chosen once on the connection. Declared empty rather than omitted,
3056
+ // so "this step takes no settings" and "nobody thought about settings"
3057
+ // stay different statements.
3058
+ settings: {},
3059
+ // BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
3060
+ // ever fires for someone with no history yet, and crossing into a
3061
+ // segment is the other moment a contact is worth pushing.
3062
+ triggers: ["lead.insert", "segment.contact.add"],
3063
+ // One source for cost: what the builder discloses before a merchant
3064
+ // adds this step, and what is charged when it runs.
3065
+ usage: { actions: 1 }
3066
+ })
3067
+ }
3068
+ },
3069
+ // WHY, in the merchant's words, and what to do about it.
2294
3070
  tasks: (data2) => {
2295
3071
  var _a;
2296
- return [
2297
- ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
2298
- {
2299
- message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
2300
- title: "Choose an audience"
2301
- }
2302
- ],
3072
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
2303
3073
  {
2304
- message: "Contact syncing to Mailchimp audiences has not shipped yet, and this connection no longer sends your email. Nothing is being sent to Mailchimp right now.",
2305
- title: "Audience sync not available yet",
2306
- type: "warning"
3074
+ message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
3075
+ title: "Choose an audience"
2307
3076
  }
2308
3077
  ];
2309
3078
  },
2310
3079
  title: "Mailchimp"
2311
3080
  };
2312
3081
 
3082
+ // lib/connections/providers/shopify.js
3083
+ var import_node_crypto4 = require("crypto");
3084
+ var import_nanoid2 = require("nanoid");
3085
+
2313
3086
  // lib/connections/icons/shopify.js
2314
3087
  var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2315
- <rect width="500" height="500" fill="white"/>
2316
- <path fill-rule="evenodd" clip-rule="evenodd" d="M309.524 407.192L308.799 128.423C306.921 126.545 303.258 127.112 301.827 127.531L292.29 130.487C291.113 126.613 289.585 122.854 287.726 119.258C280.959 106.337 271.069 99.5052 259.096 99.4866H259.059C258.259 99.4866 257.469 99.5609 256.67 99.626L256.577 99.6353C256.231 99.2089 255.871 98.7935 255.499 98.3897C250.293 92.8125 243.601 90.0889 235.588 90.3213C220.139 90.7675 204.755 101.941 192.271 121.786C183.487 135.757 176.823 153.298 174.917 166.878L144.493 176.313C135.542 179.129 135.263 179.408 134.082 187.858C133.199 194.253 109.766 375.69 109.766 375.69L306.159 409.692L309.524 407.192ZM245.181 103.065C242.569 101.346 239.511 100.546 235.885 100.621C212.033 101.308 191.23 138.611 185.923 163.467L208.771 156.384L212.851 155.119C215.845 139.336 223.355 122.957 233.181 112.416C236.616 108.639 240.671 105.477 245.172 103.065H245.181ZM224.145 151.615L256.94 141.446C257.042 132.894 256.112 120.252 251.836 111.329C247.282 113.207 243.452 116.497 240.7 119.444C233.329 127.373 227.315 139.466 224.155 151.615H224.145ZM267.211 138.267L282.455 133.536C280.02 125.616 274.238 112.342 262.517 110.111C266.161 119.527 267.099 130.431 267.211 138.267Z" fill="#95BF47"/>
2317
- <path d="M353.528 149.156C352.356 149.063 329.657 148.709 329.657 148.709C329.657 148.709 310.666 130.249 308.789 128.362C308.062 127.691 307.141 127.268 306.158 127.153V409.64L391.257 388.456C391.257 388.456 356.53 153.366 356.307 151.758C356.199 151.075 355.866 150.448 355.361 149.976C354.856 149.505 354.216 149.216 353.528 149.156Z" fill="#5E8E3E"/>
2318
- <path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
3088
+ <rect width="500" height="500" fill="#95C049"/>
3089
+ <path d="M292.997 147.633C292.997 147.633 289.98 148.495 285.023 150.004C284.161 147.202 282.868 143.969 281.144 140.521C275.54 129.744 267.134 123.925 257.22 123.925C256.574 123.925 255.927 123.925 255.065 124.141C254.849 123.71 254.418 123.494 254.203 123.063C249.892 118.322 244.289 116.166 237.607 116.382C224.676 116.813 211.744 126.081 201.399 142.676C194.071 154.314 188.468 168.97 186.959 180.393C172.088 184.919 161.743 188.152 161.527 188.367C153.984 190.738 153.768 190.954 152.906 198.066C151.613 203.454 132 355.4 132 355.4L294.937 383.633V147.202C294.075 147.418 293.429 147.418 292.997 147.633ZM255.281 159.271C246.66 161.858 237.176 164.875 227.909 167.677C230.495 157.547 235.668 147.418 241.702 140.736C244.073 138.365 247.306 135.564 250.97 133.839C254.634 141.598 255.496 152.159 255.281 159.271ZM237.823 125.003C240.84 125.003 243.427 125.649 245.582 126.943C242.133 128.667 238.685 131.469 235.452 134.702C227.262 143.538 221.012 157.332 218.426 170.479C210.667 172.85 202.908 175.22 195.796 177.376C200.322 156.901 217.779 125.649 237.823 125.003ZM212.607 243.542C213.469 257.335 249.892 260.353 252.048 292.897C253.556 318.545 238.47 336.002 216.701 337.295C190.407 339.02 175.967 323.502 175.967 323.502L181.571 299.794C181.571 299.794 196.011 310.786 207.649 309.924C215.193 309.493 217.995 303.242 217.779 298.932C216.701 280.828 186.959 281.905 185.019 252.163C183.295 227.377 199.675 202.161 235.883 199.79C249.892 198.928 257.005 202.377 257.005 202.377L248.815 233.412C248.815 233.412 239.547 229.102 228.555 229.964C212.607 231.041 212.391 241.171 212.607 243.542ZM263.902 156.685C263.902 150.219 263.039 140.952 260.022 133.193C269.936 135.133 274.678 146.124 276.833 152.806C272.954 153.883 268.643 155.176 263.902 156.685Z" fill="white"/>
3090
+ <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
2319
3091
  </svg>`;
2320
3092
 
2321
3093
  // lib/connections/inbound.js
2322
- var import_node_crypto2 = require("crypto");
2323
- var verifySignature = ({ body, descriptor, headers }) => {
3094
+ var import_node_crypto3 = require("crypto");
3095
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
3096
+ if (!secret) {
3097
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
3098
+ }
2324
3099
  const provided = headers[descriptor.headers.signature];
2325
3100
  if (!provided) {
2326
3101
  throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
2327
3102
  }
2328
- const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
3103
+ const digest = (0, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
2329
3104
  const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
2330
3105
  const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
2331
- if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
3106
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
2332
3107
  throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
2333
3108
  }
2334
3109
  return JSON.parse(body.toString());
2335
3110
  };
2336
3111
  var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
2337
3112
 
2338
- // lib/connections/shopify.js
3113
+ // lib/email.js
3114
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
3115
+ var toCanonicalEmail = (value) => {
3116
+ if (!value || typeof value !== "string") return null;
3117
+ const email = value.trim().toLowerCase();
3118
+ const at = email.lastIndexOf("@");
3119
+ if (at < 1 || at === email.length - 1) return null;
3120
+ let local = email.slice(0, at);
3121
+ const domain = email.slice(at + 1);
3122
+ const plus = local.indexOf("+");
3123
+ if (plus > 0) local = local.slice(0, plus);
3124
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
3125
+ if (!local) return null;
3126
+ return local + "@" + domain;
3127
+ };
3128
+
3129
+ // lib/connections/providers/shopify.js
3130
+ var toLine = ({
3131
+ price,
3132
+ product_id: productId,
3133
+ quantity,
3134
+ title,
3135
+ variant_id: variantId,
3136
+ variant_title: variantTitle
3137
+ }) => ({
3138
+ price: parseFloat(price) || 0,
3139
+ productId: productId ? "gid://shopify/Product/" + productId : null,
3140
+ quantity: quantity || 1,
3141
+ title: title || null,
3142
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
3143
+ variantTitle: variantTitle || null
3144
+ });
3145
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3146
+ (acc, item) => {
3147
+ const attrs = (item.properties || []).reduce(
3148
+ (map, { name, value }) => {
3149
+ map[name] = value;
3150
+ return map;
3151
+ },
3152
+ {}
3153
+ );
3154
+ if (!attrs["_drwbrdg_ca"]) return acc;
3155
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
3156
+ const line = toLine(item);
3157
+ acc.attributedGross += line.price * line.quantity;
3158
+ acc.attributedLines.push(line);
3159
+ return acc;
3160
+ },
3161
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
3162
+ );
3163
+ var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
3164
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3165
+ var OAUTH_ERROR_SOURCE = "oauth";
3166
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
2339
3167
  var inbound = {
2340
3168
  headers: {
2341
3169
  event: "x-shopify-topic",
@@ -2397,7 +3225,7 @@ var shopify_default2 = {
2397
3225
  // the App Store listing, and the dashboard must never imply a store can be
2398
3226
  // linked from inside it.
2399
3227
  redirect: {
2400
- env: "SHOPIFY_APP_LISTING_URL",
3228
+ credential: "SHOPIFY_APP_LISTING_URL",
2401
3229
  title: "View on the Shopify App Store"
2402
3230
  }
2403
3231
  },
@@ -2452,20 +3280,476 @@ var shopify_default2 = {
2452
3280
  //
2453
3281
  // `shopify` is injected for the same reason it is everywhere else — this
2454
3282
  // package cannot import @drawbridge/shopify, which depends on it.
2455
- scopes: ({ scope, shopify }) => scope ? shopify.oauth.missingScopes(scope) : null,
3283
+ scopes: ({ scope }, { shopify } = {}) => scope ? shopify.oauth.missingScopes(scope) : null,
2456
3284
  // Shopify's install grant is exchanged inside its own app flow, not
2457
3285
  // through the shared OAuth runner.
2458
3286
  token: false
2459
3287
  },
2460
- // Implemented in drawbridge-sync, which owns the attribution and the
2461
- // controllers it needs. Declared here so the steps below can point at them:
2462
- // a step naming a hook the vendor does not implement is a workflow that
2463
- // accepts the step and then silently does nothing.
3288
+ // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
3289
+ // enqueues and events for the shell to perform see contract.js and
3290
+ // everything it needs arrives as an argument: `read` (the controller's
3291
+ // read methods, nothing that writes), `shopify` (the SDK, injected because
3292
+ // this package cannot import what depends on it), `adminToken` (minted by
3293
+ // the shell, which persists rotations), `mintId` (so one described write
3294
+ // can reference another), `dispatch` (the caller's own coordinator table,
3295
+ // for the hooks that are dispatches).
2464
3296
  commerce: {
2465
- code: {},
2466
- customer: {},
2467
- order: {},
2468
- product: {}
3297
+ // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
3298
+ // one lead — which is what lets an order that redeems it be attributed
3299
+ // back.
3300
+ code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
3301
+ var _a;
3302
+ const discount = (_a = step.settings) == null ? void 0 : _a.discount;
3303
+ const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
3304
+ if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing.", request: request2, response: { skipped: true }, skipped: true };
3305
+ if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
3306
+ if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
3307
+ const adminAccessToken = await adminToken();
3308
+ if (!context.shopifyCustomerId) {
3309
+ const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
3310
+ if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
3311
+ }
3312
+ const discountCode = await shopify.admin.createDiscountCode({
3313
+ adminAccessToken,
3314
+ code: "DB-" + generateDiscountCode(),
3315
+ discountId: discount.id,
3316
+ domain: connection2.shop
3317
+ });
3318
+ if (!discountCode) return { message: "Shopify did not return a discount code \u2014 create failed.", request: request2, response: { skipped: true }, skipped: true };
3319
+ return {
3320
+ context: {
3321
+ shopifyDiscountCode: discountCode.code,
3322
+ shopifyDiscountId: String(discountCode.id)
3323
+ },
3324
+ message: "Discount code created and linked to lead.",
3325
+ request: request2,
3326
+ response: { code: discountCode.code, id: String(discountCode.id) },
3327
+ // bypassDocumentValidation because these are vendor ids on a
3328
+ // Drawbridge document the schema does not declare — the
3329
+ // canonical-identity work resolves it properly.
3330
+ writes: [{
3331
+ collection: "lead",
3332
+ data: {
3333
+ $set: {
3334
+ shopifyDiscountCode: discountCode.code,
3335
+ shopifyDiscountId: String(discountCode.id)
3336
+ }
3337
+ },
3338
+ operation: "update",
3339
+ options: { bypassDocumentValidation: true },
3340
+ query: { id: context.lead }
3341
+ }]
3342
+ };
3343
+ },
3344
+ // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
3345
+ //
3346
+ // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
3347
+ // customer at the store is a support ticket: the context may already
3348
+ // carry the id from an earlier step, the lead may already be linked from
3349
+ // an earlier run, and Shopify's own get-or-create settles the rest.
3350
+ customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
3351
+ const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
3352
+ if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
3353
+ if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
3354
+ if (context.shopifyCustomerId) {
3355
+ return {
3356
+ context: { shopifyCustomerId: context.shopifyCustomerId },
3357
+ message: "Trigger data already includes a Shopify customer id \u2014 reusing.",
3358
+ request: request2,
3359
+ response: { shopifyCustomerId: context.shopifyCustomerId },
3360
+ // Reusing an id is not a creation, so it does not bill.
3361
+ skipped: true
3362
+ };
3363
+ }
3364
+ const lead = await read.get({ collection: "lead", query: { id: context.lead } });
3365
+ if (lead == null ? void 0 : lead.shopifyCustomerId) {
3366
+ return {
3367
+ context: { shopifyCustomerId: lead.shopifyCustomerId },
3368
+ message: "Lead already has a Shopify customer id \u2014 reusing.",
3369
+ request: request2,
3370
+ response: { shopifyCustomerId: lead.shopifyCustomerId },
3371
+ skipped: true
3372
+ };
3373
+ }
3374
+ const adminAccessToken = await adminToken();
3375
+ const parts = ((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
3376
+ const customer = await shopify.admin.getOrCreateCustomer({
3377
+ adminAccessToken,
3378
+ domain: connection2.shop,
3379
+ email: context.email,
3380
+ firstName: parts.length ? parts[0] : null,
3381
+ lastName: parts.length > 1 ? parts.slice(1).join(" ") : null,
3382
+ source: "drawbridge"
3383
+ });
3384
+ if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
3385
+ return {
3386
+ context: { shopifyCustomerId: customer.id },
3387
+ message: "Shopify customer created/linked to lead.",
3388
+ request: request2,
3389
+ response: { shopifyCustomerId: customer.id },
3390
+ // The hook's own result, described beside the call that produced it.
3391
+ writes: [{
3392
+ collection: "lead",
3393
+ data: { $set: { shopifyCustomerId: customer.id } },
3394
+ operation: "update",
3395
+ options: { bypassDocumentValidation: true },
3396
+ query: { id: context.lead }
3397
+ }]
3398
+ };
3399
+ },
3400
+ // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
3401
+ // attribution genuinely is: an order can reach Drawbridge two ways and
3402
+ // they bill differently.
3403
+ //
3404
+ // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
3405
+ // add-to-cart. Causal: the campaign produced the sale, so
3406
+ // it carries a fee.
3407
+ // REDEMPTION — a DB- discount code matched to a lead. Associative: we
3408
+ // cannot claim we caused the purchase, so it is fee-free.
3409
+ //
3410
+ // Both can be true, and an order already recorded as a conversion can
3411
+ // later have a redemption backfilled onto it — `backfill` below.
3412
+ //
3413
+ // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
3414
+ // order race, the loser's transaction hits a duplicate key, the step
3415
+ // fails and BullMQ redelivers — and the re-run's read at the top finds
3416
+ // what the winner wrote and skips instead of double-billing a merchant
3417
+ // for one purchase. The hook used to loop for this itself; describing
3418
+ // the writes moved the retry to the queue, with the same guarantee.
3419
+ order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3420
+ var _a, _b, _c, _d, _e, _f;
3421
+ const {
3422
+ advertisement,
3423
+ created_at: createdAt,
3424
+ currency,
3425
+ customer: orderCustomer,
3426
+ email,
3427
+ id: orderId,
3428
+ line_items: lineItems = [],
3429
+ organization: organization2,
3430
+ phone
3431
+ } = context || {};
3432
+ const request2 = { orderId: orderId ? String(orderId) : null, organization: organization2 };
3433
+ const [existingOrder, existingRedemption] = await Promise.all([
3434
+ read.get({ collection: "order", query: { "provider.id": String(orderId), "provider.slug": "shopify" } }),
3435
+ read.get({ collection: "redemption", query: { "provider.id": String(orderId), "provider.slug": "shopify" } })
3436
+ ]);
3437
+ if (existingRedemption) {
3438
+ return {
3439
+ message: "Order/redemption already recorded \u2014 skipping duplicate.",
3440
+ request: request2,
3441
+ response: {
3442
+ existingOrderId: (existingOrder == null ? void 0 : existingOrder.id) || null,
3443
+ existingRedemptionId: existingRedemption.id,
3444
+ skipped: true
3445
+ },
3446
+ skipped: true
3447
+ };
3448
+ }
3449
+ const backfill = !!existingOrder;
3450
+ const { attrMap, attributedGross, attributedLines } = attributeLineItems(lineItems);
3451
+ const campaign = attrMap["_drwbrdg_ca"] || null;
3452
+ const discountCodes = Array.isArray(context == null ? void 0 : context.discount_codes) ? context.discount_codes : [];
3453
+ const codes = [...new Set(discountCodes.map((dc) => dc == null ? void 0 : dc.code).filter(Boolean))];
3454
+ const matchedLeads = codes.length ? await read.aggregate({
3455
+ collection: "lead",
3456
+ pipeline: [{ $match: { organization: organization2, shopifyDiscountCode: { $in: codes } } }]
3457
+ }) : [];
3458
+ const codeToLead = {};
3459
+ for (const found of matchedLeads) {
3460
+ if (found.shopifyDiscountCode) codeToLead[found.shopifyDiscountCode] = found;
3461
+ }
3462
+ const matchedDiscounts = discountCodes.filter((dc) => (dc == null ? void 0 : dc.code) && codeToLead[dc.code]).map((dc) => ({
3463
+ amount: parseFloat(dc.amount) || 0,
3464
+ code: dc.code,
3465
+ id: codeToLead[dc.code].shopifyDiscountId || null
3466
+ }));
3467
+ const matchedLead = matchedDiscounts.length ? codeToLead[matchedDiscounts[0].code] : null;
3468
+ const discount = matchedDiscounts.length ? {
3469
+ amount: matchedDiscounts.reduce((sum, entry) => sum + entry.amount, 0),
3470
+ codes: matchedDiscounts
3471
+ } : null;
3472
+ const matchedCodes = new Set(matchedDiscounts.map((entry) => entry.code));
3473
+ const unmatched = codes.filter((code2) => code2.startsWith("DB-") && !matchedCodes.has(code2));
3474
+ if (unmatched.length) {
3475
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.order.discount.unmatched", {
3476
+ campaign: campaign || null,
3477
+ codes: JSON.stringify(unmatched),
3478
+ isConversion: !!campaign,
3479
+ orderId: String(orderId),
3480
+ organization: organization2
3481
+ });
3482
+ }
3483
+ if (!campaign && !discount || backfill && !discount) {
3484
+ return {
3485
+ message: backfill ? "Order already recorded and no Drawbridge discount code matched \u2014 nothing to backfill." : "Order has no Drawbridge attribution \u2014 not recording.",
3486
+ request: request2,
3487
+ response: { skipped: true },
3488
+ skipped: true
3489
+ };
3490
+ }
3491
+ let advertisementId = null;
3492
+ let affiliateId = null;
3493
+ let campaignOrganization = organization2;
3494
+ let gross = 0;
3495
+ let leadId = null;
3496
+ let lines = [];
3497
+ let orderCampaign = null;
3498
+ let pageId = null;
3499
+ const isConversion = !!campaign;
3500
+ const customerPhone = toE164((orderCustomer == null ? void 0 : orderCustomer.phone) || phone) || null;
3501
+ const matchPhones = [...new Set([
3502
+ customerPhone,
3503
+ toE164((_b = context == null ? void 0 : context.billing_address) == null ? void 0 : _b.phone),
3504
+ toE164((_c = context == null ? void 0 : context.shipping_address) == null ? void 0 : _c.phone)
3505
+ ].filter(Boolean))];
3506
+ if (isConversion) {
3507
+ const campaignDoc = await read.get({ collection: "campaign", query: { id: campaign } });
3508
+ if (!campaignDoc || campaignDoc.organization !== organization2) {
3509
+ return {
3510
+ message: "Order carried a campaign attribution that does not belong to this store \u2014 not recording.",
3511
+ request: request2,
3512
+ response: { skipped: true },
3513
+ skipped: true
3514
+ };
3515
+ }
3516
+ advertisementId = attrMap["_drwbrdg_ad"] || advertisement || null;
3517
+ affiliateId = attrMap["_drwbrdg_af"] || null;
3518
+ campaignOrganization = campaignDoc.organization;
3519
+ gross = attributedGross;
3520
+ lines = attributedLines;
3521
+ orderCampaign = campaign;
3522
+ pageId = attrMap["_drwbrdg_pg"] || null;
3523
+ const identifiers = [];
3524
+ const canonicalEmail = toCanonicalEmail(email);
3525
+ if (email) identifiers.push({ email: email.toLowerCase() });
3526
+ if (canonicalEmail) identifiers.push({ "canonical.email.value": canonicalEmail });
3527
+ if (matchPhones.length) identifiers.push({ "phone.number": { $in: matchPhones } });
3528
+ if (matchPhones.length) identifiers.push({ "canonical.phone.value": { $in: matchPhones } });
3529
+ if (identifiers.length) {
3530
+ const lead = await read.get({
3531
+ collection: "lead",
3532
+ query: {
3533
+ campaigns: { $in: [campaign] },
3534
+ organization: campaignOrganization,
3535
+ $or: identifiers
3536
+ }
3537
+ });
3538
+ leadId = (lead == null ? void 0 : lead.id) || null;
3539
+ if (!leadId) {
3540
+ const orgLead = await read.get({
3541
+ collection: "lead",
3542
+ query: { organization: campaignOrganization, $or: identifiers }
3543
+ });
3544
+ leadId = (orgLead == null ? void 0 : orgLead.id) || null;
3545
+ }
3546
+ }
3547
+ } else {
3548
+ leadId = matchedLead.id;
3549
+ orderCampaign = (matchedLead.campaigns || []).length === 1 ? matchedLead.campaigns[0] : null;
3550
+ gross = lineItems.reduce((sum, item) => {
3551
+ const line = toLine(item);
3552
+ return sum + line.price * line.quantity;
3553
+ }, 0);
3554
+ lines = lineItems.map(toLine);
3555
+ }
3556
+ const org = await read.get({ collection: "organization", query: { id: campaignOrganization } });
3557
+ let rate = 0;
3558
+ if (isConversion) {
3559
+ const subscription = await read.get({ collection: "subscription", query: { id: org == null ? void 0 : org.subscription } });
3560
+ rate = conversionRate(subscription);
3561
+ }
3562
+ const fee = isConversion ? Math.round(gross * rate) / 100 : 0;
3563
+ const net2 = Math.round((gross - fee) * 100) / 100;
3564
+ const currencyCode = (currency || "usd").toLowerCase();
3565
+ const purchasedAt = new Date(createdAt || Date.now());
3566
+ const customer = orderCustomer || email || phone ? {
3567
+ acceptsMarketing: ((_d = orderCustomer == null ? void 0 : orderCustomer.email_marketing_consent) == null ? void 0 : _d.state) ? orderCustomer.email_marketing_consent.state === "subscribed" : typeof (orderCustomer == null ? void 0 : orderCustomer.accepts_marketing) === "boolean" ? orderCustomer.accepts_marketing : null,
3568
+ email: (orderCustomer == null ? void 0 : orderCustomer.email) || email || null,
3569
+ firstName: (orderCustomer == null ? void 0 : orderCustomer.first_name) || null,
3570
+ id: (orderCustomer == null ? void 0 : orderCustomer.id) ? String(orderCustomer.id) : null,
3571
+ lastName: (orderCustomer == null ? void 0 : orderCustomer.last_name) || null,
3572
+ phone: customerPhone
3573
+ } : null;
3574
+ const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
3575
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
3576
+ const writes = [];
3577
+ if (isConversion && !backfill) {
3578
+ writes.push({
3579
+ collection: "order",
3580
+ data: {
3581
+ advertisement: advertisementId,
3582
+ affiliate: affiliateId,
3583
+ campaign: orderCampaign,
3584
+ currency: currencyCode,
3585
+ customer,
3586
+ discount,
3587
+ fee,
3588
+ gross,
3589
+ id: orderDocId,
3590
+ lead: leadId,
3591
+ lines,
3592
+ net: net2,
3593
+ organization: campaignOrganization,
3594
+ page: pageId,
3595
+ provider: { id: String(orderId), slug: "shopify" },
3596
+ purchasedAt,
3597
+ rate,
3598
+ source,
3599
+ status: "completed"
3600
+ },
3601
+ operation: "create"
3602
+ });
3603
+ if (org == null ? void 0 : org.usage) {
3604
+ writes.push({
3605
+ collection: "usage",
3606
+ data: { $inc: { "totals.revenue": gross } },
3607
+ operation: "update",
3608
+ query: { id: org.usage }
3609
+ });
3610
+ }
3611
+ if (leadId) {
3612
+ writes.push({
3613
+ collection: "lead",
3614
+ data: { $inc: { "totals.orders": 1 } },
3615
+ operation: "update",
3616
+ options: { bypassDocumentValidation: true },
3617
+ query: { id: leadId }
3618
+ });
3619
+ }
3620
+ }
3621
+ if (discount) {
3622
+ writes.push({
3623
+ collection: "redemption",
3624
+ data: {
3625
+ advertisement: advertisementId,
3626
+ affiliate: affiliateId,
3627
+ campaign: orderCampaign,
3628
+ code: ((_e = matchedDiscounts[0]) == null ? void 0 : _e.code) || null,
3629
+ currency: currencyCode,
3630
+ customer,
3631
+ discount,
3632
+ gross,
3633
+ lead: leadId,
3634
+ order: orderDocId,
3635
+ organization: campaignOrganization,
3636
+ page: pageId,
3637
+ provider: { id: String(orderId), slug: "shopify" },
3638
+ purchasedAt,
3639
+ source,
3640
+ status: "completed"
3641
+ },
3642
+ operation: "create"
3643
+ });
3644
+ if (org == null ? void 0 : org.usage) {
3645
+ writes.push({
3646
+ collection: "usage",
3647
+ data: { $inc: { "totals.redemptions": 1 } },
3648
+ operation: "update",
3649
+ query: { id: org.usage }
3650
+ });
3651
+ }
3652
+ if (leadId) {
3653
+ writes.push({
3654
+ collection: "lead",
3655
+ data: { $inc: { "totals.redemptions": 1 } },
3656
+ operation: "update",
3657
+ options: { bypassDocumentValidation: true },
3658
+ query: { id: leadId }
3659
+ });
3660
+ }
3661
+ }
3662
+ const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3663
+ data: {
3664
+ idempotencyKey: String(orderId),
3665
+ orderDocId,
3666
+ orderId: String(orderId),
3667
+ rate,
3668
+ shopId: connection2.source.id,
3669
+ // The App Events API returns no event id, so one is generated
3670
+ // here — the event handle plus the order id — and sent as the
3671
+ // event's `reference`. queue/usage.js stamps the same id onto
3672
+ // the order as billed.transaction.
3673
+ transaction: "drawbridge-orders." + orderId,
3674
+ value: Math.round(fee * 100)
3675
+ },
3676
+ name: "billing",
3677
+ options: { jobId: "shopify.usage." + orderId },
3678
+ queue: "usage"
3679
+ }] : [];
3680
+ return {
3681
+ enqueues,
3682
+ message: backfill ? "Redemption backfilled for an already-recorded order." : isConversion ? "Order recorded." : "Discount redemption recorded (fee-free).",
3683
+ request: request2,
3684
+ response: {
3685
+ campaign: orderCampaign,
3686
+ currency: currencyCode,
3687
+ discount,
3688
+ fee,
3689
+ gross,
3690
+ lead: leadId,
3691
+ lines: lines.length,
3692
+ net: net2,
3693
+ orderId: String(orderId)
3694
+ },
3695
+ // ONE TRANSACTION. The order, the redemption and both totals
3696
+ // counters land together or not at all — a half-written attribution
3697
+ // is revenue counted twice or not at all, and neither is
3698
+ // recoverable by hand.
3699
+ transaction: writes.length > 0,
3700
+ writes
3701
+ };
3702
+ },
3703
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
3704
+ // the product pipeline; the actual field sync happens there.
3705
+ //
3706
+ // The shell has already refused a missing or inactive Shopify connection,
3707
+ // so what is left is the two things only this hook can know are wrong.
3708
+ product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
3709
+ const request2 = {
3710
+ numericId: (context == null ? void 0 : context.id) || null,
3711
+ organizationId: workflow.organization,
3712
+ title: (context == null ? void 0 : context.title) || null
3713
+ };
3714
+ if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
3715
+ if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
3716
+ const providerId = "gid://shopify/Product/" + context.id;
3717
+ const productId = mintId();
3718
+ return {
3719
+ enqueues: [{
3720
+ data: { product: productId, providerId, shop: connection2.shop },
3721
+ name: "workflow",
3722
+ options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
3723
+ queue: "product.shopify"
3724
+ }],
3725
+ message: "Product sync queued from Shopify webhook.",
3726
+ request: request2,
3727
+ response: { productId, providerId, title: (context == null ? void 0 : context.title) || null },
3728
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
3729
+ // two rows. `connections` accumulates rather than replaces: one
3730
+ // store can be linked to several organizations, and each keeps its
3731
+ // own claim on the row.
3732
+ writes: [{
3733
+ collection: "product",
3734
+ data: {
3735
+ $addToSet: { connections: connection2.id },
3736
+ $setOnInsert: {
3737
+ id: productId,
3738
+ provider: { id: providerId, slug: "shopify" },
3739
+ "source.id": connection2.id,
3740
+ status: "active"
3741
+ }
3742
+ },
3743
+ operation: "update",
3744
+ options: { upsert: true },
3745
+ query: {
3746
+ "provider.id": providerId,
3747
+ "provider.slug": "shopify",
3748
+ "source.domain": connection2.shop
3749
+ }
3750
+ }]
3751
+ };
3752
+ }
2469
3753
  },
2470
3754
  contacts: { remove: false, sync: false },
2471
3755
  // verify and event lean entirely on the shared HMAC helper — Shopify's
@@ -2483,7 +3767,16 @@ var shopify_default2 = {
2483
3767
  sms: false,
2484
3768
  inbound: {
2485
3769
  event: (args) => readEventHeader({ ...args, descriptor: inbound }),
2486
- process: {},
3770
+ // One hook over the whole topic table, because that is what this
3771
+ // manifest declares: Shopify processes its own buffered events. The
3772
+ // topic rides in on the context rather than being a second hook name per
3773
+ // topic; the caller's handler table arrives as a prop.
3774
+ process: async ({ context }, { dispatch } = {}) => {
3775
+ const key = "shopify." + (context == null ? void 0 : context.topic);
3776
+ const handled = await dispatch({ data: context == null ? void 0 : context.data, handler: key });
3777
+ if (!handled) return { message: "No handler for " + key, skipped: true };
3778
+ return { message: "Processed " + key, request: { topic: context == null ? void 0 : context.topic } };
3779
+ },
2487
3780
  receive: ({ channel, event, headers, payload }) => {
2488
3781
  if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
2489
3782
  throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
@@ -2499,7 +3792,107 @@ var shopify_default2 = {
2499
3792
  },
2500
3793
  verify: (args) => verifySignature({ ...args, descriptor: inbound })
2501
3794
  },
2502
- lifecycle: { cleanup: {}, health: {}, register: {}, rehydrate: {} },
3795
+ lifecycle: {
3796
+ // DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
3797
+ // declarations made true: the work is queue orchestration over
3798
+ // Drawbridge's own collections, which is coordinator work and stays in
3799
+ // the repo that owns the queues. The hook receives the dispatch table as
3800
+ // a prop and picks the entry, so the manifest owns the SEAM — asking
3801
+ // Shopify whether it handles its own lifecycle now gets a real function
3802
+ // instead of `unimplemented` while the work happened anyway.
3803
+ cleanup: async ({ context }, { dispatch } = {}) => {
3804
+ await dispatch({ data: context, handler: "cleanup" });
3805
+ return { message: "Ran shopify lifecycle.cleanup", request: context || null };
3806
+ },
3807
+ // KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
3808
+ // step once carried — webhooks are declarative, declared in the app's
3809
+ // toml and applied by Shopify to every install, so nothing here registers
3810
+ // or checks them.
3811
+ //
3812
+ // It rotates the refresh token before its window closes, proves the
3813
+ // access token still works, reconciles the scopes the store granted
3814
+ // against the ones the app now needs, and queues a webhook
3815
+ // reconciliation.
3816
+ health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
3817
+ const request2 = {
3818
+ connectionId: workflow.connection,
3819
+ organizationId: workflow.organization,
3820
+ shop: connection2.shop
3821
+ };
3822
+ const refreshTokenAtStart = (await resolveSettings()).refreshToken || null;
3823
+ try {
3824
+ const adminAccessToken = await adminToken();
3825
+ const settings = await resolveSettings();
3826
+ const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
3827
+ const needsRotation = refreshTokenExpiresAt && new Date(refreshTokenExpiresAt) < new Date(Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS);
3828
+ let refreshTokenRotated = false;
3829
+ if (needsRotation) {
3830
+ await rotateToken();
3831
+ refreshTokenRotated = true;
3832
+ }
3833
+ await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
3834
+ const scopesMissing = await reconcileScopes({ shop: connection2.shop });
3835
+ return {
3836
+ enqueues: [{
3837
+ data: {
3838
+ data: {
3839
+ connectionId: workflow.connection,
3840
+ organizationId: workflow.organization
3841
+ },
3842
+ event: "shopify.register.webhooks"
3843
+ },
3844
+ name: "register",
3845
+ options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto4.randomUUID)() },
3846
+ queue: "connection"
3847
+ }],
3848
+ 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.",
3849
+ request: request2,
3850
+ response: {
3851
+ pingedAt: /* @__PURE__ */ new Date(),
3852
+ refreshTokenExpiresAt: refreshTokenExpiresAt || null,
3853
+ refreshTokenRotated,
3854
+ scopesMissing,
3855
+ webhookReconciliationQueued: true
3856
+ }
3857
+ };
3858
+ } catch (error) {
3859
+ if (OAUTH_GRANT_REVOKED_CODES.includes(error.code)) {
3860
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
3861
+ const refreshTokenStored = current ? (await resolveSettings(current)).refreshToken || null : null;
3862
+ const rotated = error.code === "invalid_grant" && refreshTokenStored !== refreshTokenAtStart;
3863
+ if (current && !rotated) {
3864
+ const others = (current.errors || []).filter((entry) => entry.source !== OAUTH_ERROR_SOURCE);
3865
+ error.writes = [{
3866
+ collection: "connection",
3867
+ data: {
3868
+ $set: {
3869
+ errors: [
3870
+ ...others,
3871
+ {
3872
+ message: "Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.",
3873
+ source: OAUTH_ERROR_SOURCE
3874
+ }
3875
+ ],
3876
+ status: "error"
3877
+ }
3878
+ },
3879
+ operation: "update",
3880
+ query: { id: connection2.id }
3881
+ }];
3882
+ }
3883
+ }
3884
+ throw error;
3885
+ }
3886
+ },
3887
+ register: async ({ context }, { dispatch } = {}) => {
3888
+ await dispatch({ data: context, handler: "register" });
3889
+ return { message: "Ran shopify lifecycle.register", request: context || null };
3890
+ },
3891
+ rehydrate: async ({ context }, { dispatch } = {}) => {
3892
+ await dispatch({ data: context, handler: "rehydrate" });
3893
+ return { message: "Ran shopify lifecycle.rehydrate", request: context || null };
3894
+ }
3895
+ },
2503
3896
  resources: {
2504
3897
  audiences: false,
2505
3898
  // Shopify has no separate price resource — a price belongs to a product
@@ -2519,7 +3912,7 @@ var shopify_default2 = {
2519
3912
  // credential is the caller's job because it is Drawbridge's job: the
2520
3913
  // admin token refreshes and writes itself back, which is service work,
2521
3914
  // not vendor work.
2522
- products: async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
3915
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
2523
3916
  var _a, _b, _c, _d;
2524
3917
  const products = await shopify.storefront.getProducts({
2525
3918
  cursor,
@@ -2539,7 +3932,7 @@ var shopify_default2 = {
2539
3932
  }
2540
3933
  };
2541
3934
  },
2542
- promotions: async ({ cursor, limit = 100, search, settings, shopify }) => {
3935
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
2543
3936
  var _a, _b;
2544
3937
  const discounts = await shopify.admin.getDiscounts({
2545
3938
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -2585,6 +3978,19 @@ var shopify_default2 = {
2585
3978
  const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
2586
3979
  return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
2587
3980
  },
3981
+ // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
3982
+ // what an admin types on the provider screen. The four names below are exactly
3983
+ // what `requires` gates on, which is the point of declaring them together: a
3984
+ // name required by the manifest and enterable nowhere is a vendor that can
3985
+ // never go live from the admin screen.
3986
+ provider: {
3987
+ fields: [
3988
+ { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
3989
+ { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
3990
+ { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
3991
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
3992
+ ]
3993
+ },
2588
3994
  // A pre-launch integration: it only surfaces once the App Store listing
2589
3995
  // exists and the app is fully configured. Requiring all four means it can
2590
3996
  // never render half-configured — and absence of any one excludes the
@@ -2618,9 +4024,6 @@ var shopify_default2 = {
2618
4024
  // workflow document, and those strings cannot be renamed without a backfill.
2619
4025
  //
2620
4026
  // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2621
- // The bodies these point at still live in drawbridge-sync; moving them is the
2622
- // next unit, and commerce.order.record is the one that decides whether the
2623
- // shape holds — 569 lines and 15 controller calls.
2624
4027
  steps: {
2625
4028
  commerce: {
2626
4029
  code: {
@@ -2725,8 +4128,8 @@ var shopify_default2 = {
2725
4128
  title: "Shopify"
2726
4129
  };
2727
4130
 
2728
- // lib/connections/webhook.js
2729
- var import_node_crypto3 = __toESM(require("crypto"), 1);
4131
+ // lib/connections/providers/webhook.js
4132
+ var import_node_crypto5 = __toESM(require("crypto"), 1);
2730
4133
 
2731
4134
  // lib/safe-http.js
2732
4135
  var import_dns2 = __toESM(require("dns"), 1);
@@ -2863,7 +4266,7 @@ var safeRequest = async ({
2863
4266
  }
2864
4267
  };
2865
4268
 
2866
- // lib/connections/webhook.js
4269
+ // lib/connections/providers/webhook.js
2867
4270
  var webhook_default = {
2868
4271
  // Connecting GENERATES the secret rather than storing one the merchant typed,
2869
4272
  // so the buttons say what actually happens.
@@ -2950,16 +4353,15 @@ var webhook_default = {
2950
4353
  // That is the rule the whole split runs on: a hook lives in sync only if it
2951
4354
  // needs Drawbridge's own database, sockets or queues. This one does not.
2952
4355
  webhook: {
2953
- send: async ({ context, controller, request: send2 = safeRequest, settings, step }) => {
4356
+ send: async ({ context, lead, settings, step }, { request: send2 = safeRequest } = {}) => {
2954
4357
  const { headers = {}, method = "POST", url } = step.settings || {};
2955
4358
  const request2 = { method, url: url || null };
2956
4359
  if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
2957
- const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
2958
4360
  const body = lead || context;
2959
4361
  request2.body = body;
2960
4362
  const outgoing = { ...headers };
2961
4363
  if (settings == null ? void 0 : settings.secret) {
2962
- outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto3.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
4364
+ outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto5.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
2963
4365
  }
2964
4366
  const response = await send2({ body, headers: outgoing, method, url });
2965
4367
  return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
@@ -3030,7 +4432,7 @@ var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
3030
4432
  ([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
3031
4433
  );
3032
4434
  var build = (manifest) => {
3033
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q;
4435
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r;
3034
4436
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
3035
4437
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
3036
4438
  if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
@@ -3060,6 +4462,17 @@ var build = (manifest) => {
3060
4462
  }
3061
4463
  }
3062
4464
  }
4465
+ for (const field2 of ((_c = manifest.provider) == null ? void 0 : _c.fields) || []) {
4466
+ if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
4467
+ throw new Error(manifest.slug + " declares a provider field with no key or label");
4468
+ }
4469
+ if (!INPUTS.includes(field2.input)) {
4470
+ throw new Error(manifest.slug + ".provider." + field2.key + " needs an input the admin form can render \u2014 one of " + INPUTS.join(", "));
4471
+ }
4472
+ if (field2.input === "password" && !field2.redact) {
4473
+ throw new Error(manifest.slug + ".provider." + field2.key + " is a password and must declare redact : true \u2014 the api would hand the value back");
4474
+ }
4475
+ }
3063
4476
  if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
3064
4477
  throw new Error(manifest.slug + " needs an icon \u2014 the svg markup itself, not a path to one");
3065
4478
  }
@@ -3072,23 +4485,23 @@ var build = (manifest) => {
3072
4485
  if (!GROUPS.includes(manifest == null ? void 0 : manifest.group)) {
3073
4486
  throw new Error(manifest.slug + " needs a group \u2014 one of " + GROUPS.join(", "));
3074
4487
  }
3075
- if ((_c = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _c.type) {
4488
+ if ((_d = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _d.type) {
3076
4489
  throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
3077
4490
  }
3078
- if (!AUTH_TYPES.includes((_d = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _d.type)) {
4491
+ if (!AUTH_TYPES.includes((_e = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _e.type)) {
3079
4492
  throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
3080
4493
  }
3081
4494
  if (manifest.auth.type === "oauth") {
3082
4495
  for (const field2 of OAUTH_FIELDS) {
3083
- if (!((_e = manifest.auth.oauth) == null ? void 0 : _e[field2])) {
4496
+ if (!((_f = manifest.auth.oauth) == null ? void 0 : _f[field2])) {
3084
4497
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field2);
3085
4498
  }
3086
4499
  }
3087
- if (typeof ((_g = (_f = manifest.hooks) == null ? void 0 : _f.auth) == null ? void 0 : _g.token) !== "function") {
4500
+ if (typeof ((_h = (_g = manifest.hooks) == null ? void 0 : _g.auth) == null ? void 0 : _h.token) !== "function") {
3088
4501
  throw new Error(manifest.slug + " is oauth and must implement hooks.auth.token \u2014 point it at authToken() or wrap it");
3089
4502
  }
3090
4503
  for (const url of OAUTH_URLS) {
3091
- if (!((_i = (_h = manifest.auth.oauth) == null ? void 0 : _h.urls) == null ? void 0 : _i[url])) {
4504
+ if (!((_j = (_i = manifest.auth.oauth) == null ? void 0 : _i.urls) == null ? void 0 : _j[url])) {
3092
4505
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth.urls." + url);
3093
4506
  }
3094
4507
  }
@@ -3098,16 +4511,16 @@ var build = (manifest) => {
3098
4511
  );
3099
4512
  }
3100
4513
  }
3101
- if (implemented(manifest.hooks, "inbound.event") && !((_k = (_j = manifest.inbound) == null ? void 0 : _j.headers) == null ? void 0 : _k.event)) {
4514
+ if (implemented(manifest.hooks, "inbound.event") && !((_l = (_k = manifest.inbound) == null ? void 0 : _k.headers) == null ? void 0 : _l.event)) {
3102
4515
  throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
3103
4516
  }
3104
- if (implemented(manifest.hooks, "inbound.verify") && !((_m = (_l = manifest.inbound) == null ? void 0 : _l.headers) == null ? void 0 : _m.signature)) {
4517
+ if (implemented(manifest.hooks, "inbound.verify") && !((_n = (_m = manifest.inbound) == null ? void 0 : _m.headers) == null ? void 0 : _n.signature)) {
3105
4518
  throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
3106
4519
  }
3107
4520
  if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
3108
4521
  throw new Error(manifest.slug + " must declare status( data ) \u2014 return null to accept the connection's own status, or { message, status } to override it");
3109
4522
  }
3110
- if (!Array.isArray((_n = manifest == null ? void 0 : manifest.content) == null ? void 0 : _n.guide) || !manifest.content.guide.length) {
4523
+ if (!Array.isArray((_o = manifest == null ? void 0 : manifest.content) == null ? void 0 : _o.guide) || !manifest.content.guide.length) {
3111
4524
  throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
3112
4525
  }
3113
4526
  if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
@@ -3119,8 +4532,8 @@ var build = (manifest) => {
3119
4532
  }
3120
4533
  for (const [domain, verbs] of Object.entries(HOOKS)) {
3121
4534
  for (const verb of verbs) {
3122
- const hook = (_p = (_o = manifest.hooks) == null ? void 0 : _o[domain]) == null ? void 0 : _p[verb];
3123
- if (((_q = manifest.hooks) == null ? void 0 : _q[domain]) === false) continue;
4535
+ const hook = (_q = (_p = manifest.hooks) == null ? void 0 : _p[domain]) == null ? void 0 : _q[verb];
4536
+ if (((_r = manifest.hooks) == null ? void 0 : _r[domain]) === false) continue;
3124
4537
  if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
3125
4538
  throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
3126
4539
  }
@@ -3171,14 +4584,18 @@ var connections = Object.freeze({
3171
4584
  webhook: build(webhook_default)
3172
4585
  });
3173
4586
  (() => {
3174
- const owners = {};
4587
+ var _a;
4588
+ const routes = {};
3175
4589
  for (const [slug, manifest] of Object.entries(connections)) {
3176
- for (const [name] of leaves(manifest.steps)) {
4590
+ for (const [name, step] of leaves(manifest.steps)) {
3177
4591
  const type = "step." + name;
3178
- if (owners[type]) {
3179
- throw new Error("Step " + type + " is declared by both " + owners[type] + " and " + slug);
4592
+ const queue = (_a = step({})) == null ? void 0 : _a.queue;
4593
+ if (routes[type] && routes[type].queue !== queue) {
4594
+ throw new Error(
4595
+ "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
4596
+ );
3180
4597
  }
3181
- owners[type] = slug;
4598
+ routes[type] = { queue, slug };
3182
4599
  }
3183
4600
  }
3184
4601
  })();
@@ -3225,10 +4642,13 @@ var connectFields = (slug) => {
3225
4642
  secret: Boolean(redact)
3226
4643
  }));
3227
4644
  };
3228
- var runHook = async (slug, name, args = {}) => {
4645
+ var runHook = async (slug, name, props = {}, options = {}) => {
3229
4646
  const manifest = connections[slug];
3230
4647
  if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug };
3231
- const hook = name.split(".").reduce((node, key) => node == null ? void 0 : node[key], manifest.hooks);
4648
+ const hook = name.split(".").reduce(
4649
+ (node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
4650
+ manifest.hooks
4651
+ );
3232
4652
  if (hook === false || hook == null) {
3233
4653
  return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
3234
4654
  }
@@ -3236,7 +4656,9 @@ var runHook = async (slug, name, args = {}) => {
3236
4656
  return { outcome: OUTCOMES.unimplemented, reason: slug + " implements " + name + " outside this package" };
3237
4657
  }
3238
4658
  try {
3239
- return { outcome: OUTCOMES.answered, result: await hook({ ...args, manifest }) };
4659
+ const result = await hook({ ...props, manifest }, options);
4660
+ effectsOf(result);
4661
+ return { outcome: OUTCOMES.answered, result };
3240
4662
  } catch (error) {
3241
4663
  return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
3242
4664
  }
@@ -3320,7 +4742,7 @@ var projectConnection = (record) => {
3320
4742
  var resolveConnection = (item, data2, env = {}) => {
3321
4743
  if (!item) return item;
3322
4744
  return Object.fromEntries(
3323
- Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
4745
+ Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "provider", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
3324
4746
  key,
3325
4747
  typeof value === "function" ? value(data2, env) : value
3326
4748
  ])
@@ -3331,7 +4753,10 @@ var resolveConnection = (item, data2, env = {}) => {
3331
4753
  AUTH_TYPES,
3332
4754
  GROUPS,
3333
4755
  HOOKS,
4756
+ HOOK_EFFECTS,
3334
4757
  HOOK_NAMES,
4758
+ HOOK_OPTIONS,
4759
+ HOOK_PROPS,
3335
4760
  INPUTS,
3336
4761
  OAUTH_FIELDS,
3337
4762
  OUTCOMES,
@@ -3339,6 +4764,7 @@ var resolveConnection = (item, data2, env = {}) => {
3339
4764
  STATUSES,
3340
4765
  STEPS,
3341
4766
  STEP_TYPES,
4767
+ WRITE_OPERATIONS,
3342
4768
  accessToken,
3343
4769
  authToken,
3344
4770
  availableConnections,
@@ -3348,6 +4774,7 @@ var resolveConnection = (item, data2, env = {}) => {
3348
4774
  connectionSteps,
3349
4775
  connections,
3350
4776
  consentUrl,
4777
+ effectsOf,
3351
4778
  hookSupport,
3352
4779
  isStale,
3353
4780
  mergeSettings,