@drawbridge/drawbridge-utils 0.0.162 → 0.0.164

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.
@@ -26,40 +26,102 @@ var phrasesIn = (value) => {
26
26
  return PHRASES.filter((phrase) => lower.includes(phrase));
27
27
  };
28
28
  var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
29
- var subjectWarnings = ({ brand, subject }) => {
29
+ var POINTS = {
30
+ brand: 0,
31
+ currency: 1,
32
+ emoji: 0.5,
33
+ length: 0.5,
34
+ phrase: 1,
35
+ punctuation: 1,
36
+ shouting: 1.5
37
+ };
38
+ var subjectRules = ({ brand, subject }) => {
30
39
  const value = String(subject || "").trim();
31
40
  if (!value) return [];
32
41
  const found = phrasesIn(value);
33
42
  const emojis = emojiCount(value);
34
43
  return [
35
- value.length > SUBJECT_DISPLAY_LIMIT && "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
36
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
37
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
38
- emojis > 1 && "More than one emoji in a subject is a common spam signal.",
39
- /[$£€]\s?\d/.test(value) && "A currency amount in the subject is a common spam signal.",
40
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + ".",
41
- brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more."
42
- ].filter(Boolean);
44
+ value.length > SUBJECT_DISPLAY_LIMIT && {
45
+ message: "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
46
+ points: POINTS.length,
47
+ rule: "length"
48
+ },
49
+ shouting(value) && {
50
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
51
+ points: POINTS.shouting,
52
+ rule: "shouting"
53
+ },
54
+ /[!?]{2,}/.test(value) && {
55
+ message: "Repeated exclamation or question marks are a common spam signal.",
56
+ points: POINTS.punctuation,
57
+ rule: "punctuation"
58
+ },
59
+ emojis > 1 && {
60
+ message: "More than one emoji in a subject is a common spam signal.",
61
+ points: POINTS.emoji,
62
+ rule: "emoji"
63
+ },
64
+ /[$£€]\s?\d/.test(value) && {
65
+ message: "A currency amount in the subject is a common spam signal.",
66
+ points: POINTS.currency,
67
+ rule: "currency"
68
+ },
69
+ found.length > 0 && {
70
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
71
+ points: POINTS.phrase * found.length,
72
+ rule: "phrase"
73
+ },
74
+ brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && {
75
+ message: "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more.",
76
+ points: POINTS.brand,
77
+ rule: "brand"
78
+ }
79
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
43
80
  };
44
- var messageWarnings = ({ message }) => {
81
+ var messageRules = ({ message }) => {
45
82
  const value = String(message || "").trim();
46
83
  if (!value) return [];
47
84
  const found = phrasesIn(value);
48
85
  return [
49
- value.length < MESSAGE_SHORT_LIMIT && "Very short messages read as a fragment, and bulk senders send exactly this shape.",
50
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
51
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
52
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + "."
53
- ].filter(Boolean);
86
+ value.length < MESSAGE_SHORT_LIMIT && {
87
+ message: "Very short messages read as a fragment, and bulk senders send exactly this shape.",
88
+ points: POINTS.length,
89
+ rule: "length"
90
+ },
91
+ shouting(value) && {
92
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
93
+ points: POINTS.shouting,
94
+ rule: "shouting"
95
+ },
96
+ /[!?]{2,}/.test(value) && {
97
+ message: "Repeated exclamation or question marks are a common spam signal.",
98
+ points: POINTS.punctuation,
99
+ rule: "punctuation"
100
+ },
101
+ found.length > 0 && {
102
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
103
+ points: POINTS.phrase * found.length,
104
+ rule: "phrase"
105
+ }
106
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
54
107
  };
55
- var notificationWarnings = ({
108
+ var notificationRules = ({
56
109
  brand,
57
110
  message,
58
111
  subject
59
- } = {}) => ({
60
- message: messageWarnings({ message }),
61
- subject: subjectWarnings({ brand, subject })
62
- });
112
+ } = {}) => [
113
+ ...subjectRules({ brand, subject }),
114
+ ...messageRules({ message })
115
+ ];
116
+ var notificationWarnings = (input) => {
117
+ const rules = notificationRules(input);
118
+ return {
119
+ message: rules.filter((rule) => rule.field === "message").map((rule) => rule.message),
120
+ subject: rules.filter((rule) => rule.field === "subject").map((rule) => rule.message)
121
+ };
122
+ };
63
123
  export {
124
+ POINTS,
125
+ notificationRules,
64
126
  notificationWarnings
65
127
  };
@@ -36,11 +36,14 @@ __export(providers_exports, {
36
36
  providerEnvNames: () => providerEnvNames,
37
37
  providerFields: () => providerFields,
38
38
  providerMemo: () => providerMemo,
39
+ providerRow: () => providerRow,
39
40
  providerSettings: () => providerSettings,
40
41
  providerSlugs: () => providerSlugs,
41
42
  saveProviderSettings: () => saveProviderSettings,
43
+ vendorEnabled: () => vendorEnabled,
42
44
  vendorSettings: () => vendorSettings,
43
- vendors: () => vendors
45
+ vendors: () => vendors,
46
+ vendorsEnabled: () => vendorsEnabled
44
47
  });
45
48
  module.exports = __toCommonJS(providers_exports);
46
49
 
@@ -540,10 +543,15 @@ var attentive_default2 = {
540
543
  "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."
541
544
  ],
542
545
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
546
+ // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
547
+ // no segment chosen sits at Pending, and a guide that stops at the consent
548
+ // leaves a merchant looking at a connection they think is broken. Same
549
+ // omission Klaviyo's guide already carries a note about.
543
550
  guide: [
544
551
  "Press Connect. Drawbridge sends you to Attentive to approve access.",
545
552
  "Sign in to Attentive if you are not already, and authorize the permissions listed.",
546
- "You are returned here \u2014 choose which Attentive segment your contacts should sync into."
553
+ "You come back here to choose which Attentive segment your contacts should sync into.",
554
+ "The connection shows Pending until you choose a segment, then Active."
547
555
  ]
548
556
  },
549
557
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
@@ -806,6 +814,9 @@ var attentive_default2 = {
806
814
  // merchant would recognise in a builder label.
807
815
  key: "Sync contact to Attentive",
808
816
  queue: "connection",
817
+ // Attentive applies the update in the background and returns no id we
818
+ // could hand on, so there is nothing for a later step to interpolate.
819
+ returns: [],
809
820
  // Nothing for a merchant to configure on the step itself — the segment
810
821
  // is chosen once on the connection. Declared empty rather than omitted,
811
822
  // so "this step takes no settings" and "nobody thought about settings"
@@ -2033,13 +2044,18 @@ var drawbridge_default2 = {
2033
2044
  request: request2,
2034
2045
  response: { count, notified: recipients.length },
2035
2046
  writes: recipients.map((member) => {
2036
- var _a2, _b2;
2047
+ var _a2, _b2, _c2;
2037
2048
  return queueNotification({
2038
2049
  audience: "member",
2039
2050
  message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
2040
2051
  organization: workflow.organization,
2041
2052
  send: { type: "email", email: member.email },
2042
- title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
2053
+ // The editor's plain rendering, interpolated the same way so
2054
+ // {{count}} resolves in the text part too. Spread rather than
2055
+ // written as null: a step saved before `text` existed has none,
2056
+ // and the send path strips the markup itself for those.
2057
+ ...((_b2 = step.settings) == null ? void 0 : _b2.text) && { text: interpolate(step.settings.text, values) },
2058
+ title: interpolate((_c2 = step.settings) == null ? void 0 : _c2.subject, values),
2043
2059
  workflow: workflow.id
2044
2060
  });
2045
2061
  })
@@ -2108,7 +2124,7 @@ var drawbridge_default2 = {
2108
2124
  // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
2109
2125
  // and what, correctly, and to refuse early when it must not send at all.
2110
2126
  send: async ({ context, step, workflow }, { canSend, read } = {}) => {
2111
- var _a, _b;
2127
+ var _a, _b, _c;
2112
2128
  const to = context == null ? void 0 : context.email;
2113
2129
  if (!to) throw new Error("No email address on context (context.email is required)");
2114
2130
  const request2 = { to };
@@ -2145,7 +2161,11 @@ var drawbridge_default2 = {
2145
2161
  message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
2146
2162
  organization: workflow.organization,
2147
2163
  send: { type: "email", email: to },
2148
- title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
2164
+ // The editor's plain rendering, so the text/plain part of this
2165
+ // email is what the merchant wrote rather than a regex's guess at
2166
+ // it. See the digest above for why it is spread, not nulled.
2167
+ ...((_b = step.settings) == null ? void 0 : _b.text) && { text: interpolate(step.settings.text, context) },
2168
+ title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
2149
2169
  workflow: workflow.id
2150
2170
  })
2151
2171
  ]
@@ -2565,6 +2585,11 @@ var drawbridge_default2 = {
2565
2585
  hook: "email.digest",
2566
2586
  key: "Email \u2014 Digest",
2567
2587
  queue: "notification",
2588
+ // Nothing for a later step to interpolate. Declared empty rather than
2589
+ // omitted, so "this step leaves nothing behind" and "nobody thought
2590
+ // about it" stay different statements — the second is what emptied the
2591
+ // builder's Variables menu for every step in the product.
2592
+ returns: [],
2568
2593
  settings: {
2569
2594
  // The organization OWNER is always a recipient, resolved by the
2570
2595
  // hook, so this is additional recipients rather than the list. It
@@ -2573,7 +2598,22 @@ var drawbridge_default2 = {
2573
2598
  // pick and could never save the step.
2574
2599
  members: { of: "string", type: "array" },
2575
2600
  message: { max: 2e3, required: true, type: "string" },
2576
- subject: { max: 150, required: true, type: "string" }
2601
+ // THE LAST SPAM CHECK, stored beside the copy it measured. Shape is
2602
+ // pinned no further than the pair that proves the match, which is the
2603
+ // only part the api reads; the rest is SpamAssassin's own output and
2604
+ // is stored as it arrives. Same declaration the campaign's draw
2605
+ // notification validates against.
2606
+ //
2607
+ // Undeclared, this was silently dropped at submit — the settings
2608
+ // validator is noUnknown().strict(), so the dashboard's step form
2609
+ // mapped the key away rather than 400 — and every open of a workflow
2610
+ // step re-ran a check against a free service to be told what the last
2611
+ // open had already been told.
2612
+ score: { shape: { message: { type: "string" }, subject: { type: "string" } }, type: "object" },
2613
+ subject: { max: 150, required: true, type: "string" },
2614
+ // The editor's plain rendering of `message`, written beside it rather
2615
+ // than reconstructed by running a regex over the markup at send time.
2616
+ text: { max: 2e3, type: "string" }
2577
2617
  },
2578
2618
  triggers: ["schedule.day", "schedule.week", "schedule.month"],
2579
2619
  usage: { actions: 0 }
@@ -2594,6 +2634,7 @@ var drawbridge_default2 = {
2594
2634
  hook: "email.notify",
2595
2635
  key: "Email \u2014 Notification",
2596
2636
  queue: "notification",
2637
+ returns: [],
2597
2638
  settings: {
2598
2639
  members: { of: "string", type: "array" },
2599
2640
  message: { max: 2e3, type: "string" },
@@ -2610,9 +2651,14 @@ var drawbridge_default2 = {
2610
2651
  hook: "email.send",
2611
2652
  key: "Email \u2014 Send email",
2612
2653
  queue: "notification",
2654
+ returns: [],
2613
2655
  settings: {
2614
2656
  message: { max: 2e3, required: true, type: "string" },
2615
- subject: { max: 150, required: true, type: "string" }
2657
+ // See email.digest above the last spam check, and the editor's
2658
+ // plain rendering of the message.
2659
+ score: { shape: { message: { type: "string" }, subject: { type: "string" } }, type: "object" },
2660
+ subject: { max: 150, required: true, type: "string" },
2661
+ text: { max: 2e3, type: "string" }
2616
2662
  },
2617
2663
  triggers: ["lead.insert"],
2618
2664
  // ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
@@ -3211,6 +3257,13 @@ var klaviyo_default2 = {
3211
3257
  // than a label that could be any of their Klaviyo accounts.
3212
3258
  key: "Sync contact to " + (((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.account) || "Klaviyo"),
3213
3259
  queue: "connection",
3260
+ // The key the hook puts in `context`, which the runner merges into the
3261
+ // run so a later step can interpolate {{klaviyoProfileId}}. Declared
3262
+ // beside the hook that writes it — see the note on Shopify's
3263
+ // commerce.code, which is where this came back from.
3264
+ returns: [
3265
+ { key: "klaviyoProfileId", label: "Klaviyo Profile ID" }
3266
+ ],
3214
3267
  // Nothing for a merchant to configure on the step itself — the list
3215
3268
  // is chosen once on the connection. Declared empty rather than
3216
3269
  // omitted, so "this step takes no settings" and "nobody thought about
@@ -3326,10 +3379,15 @@ var mailchimp_default2 = {
3326
3379
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3327
3380
  ],
3328
3381
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
3382
+ // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
3383
+ // no audience chosen sits at Pending, and a guide that stops at the consent
3384
+ // leaves a merchant looking at a connection they think is broken.
3329
3385
  guide: [
3330
3386
  "Press Connect. Drawbridge sends you to Mailchimp to approve access.",
3331
3387
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3332
- "You come back here to pick the audience your contacts should sync into."
3388
+ "You come back here to pick the audience your contacts should sync into.",
3389
+ "The connection shows Pending until you pick an audience, then Active.",
3390
+ "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3333
3391
  ]
3334
3392
  },
3335
3393
  // Mailchimp and SendGrid shared a group while they were SENDERS, where an org
@@ -3571,6 +3629,11 @@ var mailchimp_default2 = {
3571
3629
  // than showing a string like a1b2c3d4e5.
3572
3630
  key: "Sync contact to Mailchimp",
3573
3631
  queue: "connection",
3632
+ // The key the hook puts in `context`, for a later step to interpolate
3633
+ // as {{mailchimpMemberId}}. Same reasoning as Klaviyo's.
3634
+ returns: [
3635
+ { key: "mailchimpMemberId", label: "Mailchimp Member ID" }
3636
+ ],
3574
3637
  // Nothing for a merchant to configure on the step itself — the audience
3575
3638
  // is chosen once on the connection. Declared empty rather than omitted,
3576
3639
  // so "this step takes no settings" and "nobody thought about settings"
@@ -4245,8 +4308,8 @@ var shopify_default2 = {
4245
4308
  }
4246
4309
  }
4247
4310
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4248
- const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
4249
- const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
4311
+ const providerRow2 = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
4312
+ const { orderEventHandle } = (providerRow2 == null ? void 0 : providerRow2.settings) ? decrypt(providerRow2.settings) : {};
4250
4313
  const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
4251
4314
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
4252
4315
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
@@ -4851,6 +4914,24 @@ var shopify_default2 = {
4851
4914
  hook: "commerce.code",
4852
4915
  key: "Issue a discount code",
4853
4916
  queue: "connection",
4917
+ // WHAT THIS STEP LEAVES BEHIND FOR THE ONES AFTER IT, and the only
4918
+ // reason the builder can offer a Variables menu.
4919
+ //
4920
+ // These are the exact keys the hook puts in `context` — the runner
4921
+ // merges that into the run's context, and a later step interpolates
4922
+ // {{shopifyDiscountCode}} out of it. Declared beside the hook that
4923
+ // writes them so the two cannot drift; there is nowhere else that
4924
+ // knows both.
4925
+ //
4926
+ // They existed as a hand-written list in the api's workflow catalog
4927
+ // until it was derived from these manifests, and the derivation
4928
+ // hardcoded `returns : []` for every step. Nothing has offered a
4929
+ // variable since — an email step after a discount step had no way to
4930
+ // name the code it was supposed to send.
4931
+ returns: [
4932
+ { key: "shopifyDiscountCode", label: "Shopify Discount Code" },
4933
+ { key: "shopifyDiscountId", label: "Shopify Discount ID" }
4934
+ ],
4854
4935
  settings: {
4855
4936
  discount: {
4856
4937
  required: true,
@@ -4869,6 +4950,12 @@ var shopify_default2 = {
4869
4950
  hook: "commerce.customer",
4870
4951
  key: "Create customer",
4871
4952
  queue: "connection",
4953
+ // Written on every path the hook can take — created, reused from an
4954
+ // earlier step, or reused from the lead — so a later step can rely on
4955
+ // it being there whenever this one succeeded.
4956
+ returns: [
4957
+ { key: "shopifyCustomerId", label: "Shopify Customer ID" }
4958
+ ],
4872
4959
  settings: {},
4873
4960
  triggers: ["lead.insert"],
4874
4961
  usage: { actions: 1 }
@@ -5266,6 +5353,13 @@ var webhook_default = {
5266
5353
  hook: "webhook.send",
5267
5354
  key: "Send webhook",
5268
5355
  queue: "webhook",
5356
+ // The receiver's response is buffered, not merged into the run — a
5357
+ // receiver owes us a status, not a document — so there is nothing here
5358
+ // for a later step to interpolate. Declared empty rather than omitted:
5359
+ // "leaves nothing behind" and "nobody thought about it" are different
5360
+ // statements, and the second one is what emptied the builder's
5361
+ // Variables menu for every step in the product.
5362
+ returns: [],
5269
5363
  settings: {
5270
5364
  url: { format: "url", required: true, type: "string" }
5271
5365
  },
@@ -5559,6 +5653,12 @@ var publicConnectionKeys = Object.freeze([
5559
5653
  // excerpt, guide, and any vendor redirect copy.
5560
5654
  "content",
5561
5655
  "createdAt",
5656
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5657
+ // paused — kept, configured, and not run — as distinct from a vendor the
5658
+ // platform switched off (which the api overlays as an error). Absent means
5659
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5660
+ // connection the way they refuse a missing one.
5661
+ "enabled",
5562
5662
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5563
5663
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5564
5664
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -5599,17 +5699,25 @@ var mask = (value) => {
5599
5699
  var providerMemo = /* @__PURE__ */ new Map();
5600
5700
  var MEMO_TTL_MS = 60 * 1e3;
5601
5701
  var clearProviderMemo = () => providerMemo.clear();
5602
- var providerSettings = async ({ controller, slug: slug2 }) => {
5702
+ var providerRow = async ({ controller, slug: slug2 }) => {
5603
5703
  const memoized = providerMemo.get(slug2);
5604
5704
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
5605
5705
  const row = await controller.get({
5606
5706
  collection: "provider",
5607
5707
  query: { slug: slug2 }
5608
5708
  });
5609
- const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
5709
+ const value = {
5710
+ enabled: (row == null ? void 0 : row.enabled) !== false,
5711
+ settings: (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {}
5712
+ };
5610
5713
  providerMemo.set(slug2, { at: Date.now(), value });
5611
5714
  return value;
5612
5715
  };
5716
+ var providerSettings = async ({ controller, includeDisabled = false, slug: slug2 }) => {
5717
+ const row = await providerRow({ controller, slug: slug2 });
5718
+ return row.enabled || includeDisabled ? row.settings : {};
5719
+ };
5720
+ var vendorEnabled = async ({ controller, vendor }) => (await providerRow({ controller, slug: vendor })).enabled;
5613
5721
  var vendorSettings = async ({ controller, slug: slug2 }) => {
5614
5722
  var _a;
5615
5723
  const declared = Object.hasOwn(connections, slug2) ? Object.keys(((_a = connections[slug2].provider) == null ? void 0 : _a.vendors) || {}) : [];
@@ -5619,7 +5727,15 @@ var vendorSettings = async ({ controller, slug: slug2 }) => {
5619
5727
  }
5620
5728
  return merged;
5621
5729
  };
5622
- var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug: slug2 }) => {
5730
+ var vendorsEnabled = async ({ controller, slug: slug2 }) => {
5731
+ var _a;
5732
+ const declared = Object.hasOwn(connections, slug2) ? Object.keys(((_a = connections[slug2].provider) == null ? void 0 : _a.vendors) || {}) : [];
5733
+ for (const vendor of declared) {
5734
+ if (!await vendorEnabled({ controller, vendor })) return false;
5735
+ }
5736
+ return true;
5737
+ };
5738
+ var saveProviderSettings = async ({ authenticated, clear, controller, enabled, settings, slug: slug2 }) => {
5623
5739
  const fields2 = providerFields(slug2);
5624
5740
  if (!fields2.length) return null;
5625
5741
  const existing = await controller.get({
@@ -5641,6 +5757,7 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5641
5757
  collection: "provider",
5642
5758
  data: {
5643
5759
  $set: {
5760
+ ...enabled !== void 0 && { enabled: Boolean(enabled) },
5644
5761
  settings: encrypt(merged)
5645
5762
  }
5646
5763
  },
@@ -5655,11 +5772,11 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5655
5772
  var providerEnvNames = () => new Set(
5656
5773
  providerSlugs().flatMap((slug2) => providerFields(slug2)).map((field2) => field2.credential).filter(Boolean)
5657
5774
  );
5658
- var providerCredentials = async ({ controller }) => {
5775
+ var providerCredentials = async ({ controller, includeDisabled = false }) => {
5659
5776
  const credentials2 = {};
5660
5777
  for (const slug2 of providerSlugs()) {
5661
5778
  try {
5662
- const settings = await providerSettings({ controller, slug: slug2 });
5779
+ const settings = await providerSettings({ controller, includeDisabled, slug: slug2 });
5663
5780
  for (const field2 of providerFields(slug2)) {
5664
5781
  const value = settings == null ? void 0 : settings[field2.key];
5665
5782
  if (field2.credential && value) credentials2[field2.credential] = value;
@@ -5679,9 +5796,12 @@ var providerCredentials = async ({ controller }) => {
5679
5796
  providerEnvNames,
5680
5797
  providerFields,
5681
5798
  providerMemo,
5799
+ providerRow,
5682
5800
  providerSettings,
5683
5801
  providerSlugs,
5684
5802
  saveProviderSettings,
5803
+ vendorEnabled,
5685
5804
  vendorSettings,
5686
- vendors
5805
+ vendors,
5806
+ vendorsEnabled
5687
5807
  });
@@ -128,8 +128,9 @@ const mask = ( value ) => {
128
128
  // safe. Per-process state cannot be stale for anyone but the process holding it,
129
129
  // and it expires on its own.
130
130
  //
131
- // slug -> { at, value }, where value is the DECRYPTED settings. Exported only so
132
- // tests can age an entry without sleeping; nothing else should read it.
131
+ // slug -> { at, value }, where value is { enabled, settings } the decrypted
132
+ // settings and whether the row is switched on. Exported only so tests can age an
133
+ // entry without sleeping; nothing else should read it.
133
134
  const providerMemo = new Map();
134
135
 
135
136
  // SIXTY SECONDS, chosen for the human rather than for the load. The read is one
@@ -143,16 +144,35 @@ const MEMO_TTL_MS = 60 * 1000;
143
144
  // For tests. A save clears its own slug; this clears everything.
144
145
  const clearProviderMemo = () => providerMemo.clear();
145
146
 
146
- // Read a vendor's credentials, memoized, because this is on the path of every
147
- // OAuth callback and every send.
147
+ // A VENDOR, as { enabled, settings } the switch and the credentials, from one
148
+ // read. Memoized, because this is on the path of every OAuth callback and every
149
+ // send, so a guard costs a Map lookup rather than a document and a decrypt.
148
150
  //
149
- // An unconfigured vendor answers {} rather than throwing: the admin list has to
150
- // render the row that lets someone fix it.
151
+ // THIS IS THE ROOT CHECK. There is no separate is-it-enabled function, because
152
+ // one question with two ways to ask it is two things to keep agreeing:
153
+ //
154
+ // const { enabled, settings } = await providerRow({ controller, slug : 'sendgrid' });
155
+ // if( ! enabled || ! settings.apiKey ) return;
156
+ //
157
+ // The switch is answered here and the caller names the credentials it needs.
158
+ // Which credentials matter is the FEATURE's question, not the vendor's, and
159
+ // folding the two together answers it wrong in both directions: `isLive` judges
160
+ // the fields a vendor marks `required`, so a send needing SendGrid's optional
161
+ // eventKey would pass a combined check and still fail, while one needing nothing
162
+ // but eventKey would be refused because accountSender happens to be blank. A
163
+ // vendor has one switch and many features.
164
+ //
165
+ // An unconfigured vendor answers enabled with no settings rather than throwing:
166
+ // the admin list has to render the row that lets someone fix it.
167
+ //
168
+ // ABSENT MEANS ON. Every row that predates the switch has no `enabled` field,
169
+ // and reading that as off would take every vendor down at once — so the flag is
170
+ // only ever believed when it says false.
151
171
  //
152
172
  // The memoized object is handed to every reader inside the window rather than
153
173
  // copied per read — no caller mutates its credentials, and defending against one
154
174
  // that does not exist would cost a clone on every send.
155
- const providerSettings = async ({ controller, slug }) => {
175
+ const providerRow = async ({ controller, slug }) => {
156
176
 
157
177
  const memoized = providerMemo.get( slug );
158
178
 
@@ -163,7 +183,10 @@ const providerSettings = async ({ controller, slug }) => {
163
183
  query : { slug }
164
184
  });
165
185
 
166
- const value = row?.settings ? decrypt( row.settings ) : {};
186
+ const value = {
187
+ enabled : row?.enabled !== false,
188
+ settings : row?.settings ? decrypt( row.settings ) : {}
189
+ };
167
190
 
168
191
  providerMemo.set( slug, { at : Date.now(), value });
169
192
 
@@ -171,6 +194,39 @@ const providerSettings = async ({ controller, slug }) => {
171
194
 
172
195
  };
173
196
 
197
+ // A vendor's credentials, or nothing at all if the vendor is switched off.
198
+ //
199
+ // SWITCHED OFF READS AS UNCONFIGURED, and that is the whole design. Every
200
+ // consumer already handles a vendor with no credentials — availableConnections
201
+ // drops it from the catalogue, isLive reports not live, a send has nothing to
202
+ // send with — so turning one off needs no second code path anywhere. The keys
203
+ // stay on the row, which is the point: this is the switch you reach for instead
204
+ // of deleting a credential you will have to find again.
205
+ //
206
+ // `includeDisabled` is for the ADMIN SCREEN alone. That page exists to show what
207
+ // is stored and to switch it back on, and it cannot do either if the credentials
208
+ // disappear from its own view the moment they are turned off.
209
+ const providerSettings = async ({ controller, includeDisabled = false, slug }) => {
210
+
211
+ const row = await providerRow({ controller, slug });
212
+
213
+ return ( row.enabled || includeDisabled ) ? row.settings : {};
214
+
215
+ };
216
+
217
+ // IS THIS VENDOR SWITCHED ON? The primitive both halves of the gate are built
218
+ // from, named for what it takes: a VENDOR slug (sendgrid, twilio, klaviyo), not
219
+ // a connection slug. The `provider` collection is one row per vendor, so this is
220
+ // that row's switch and nothing else.
221
+ //
222
+ // Its parameter is `vendor` rather than `slug` on purpose. vendorsEnabled below
223
+ // takes a CONNECTION slug and composes this across everything that connection
224
+ // spends, and the two are a letter apart — the argument name is what makes which
225
+ // one you are calling obvious at the call site.
226
+ const vendorEnabled = async ({ controller, vendor }) => (
227
+ ( await providerRow({ controller, slug : vendor }) ).enabled
228
+ );
229
+
174
230
  // EVERYTHING A CONNECTION SPENDS, merged across the vendors its manifest
175
231
  // declares. `drawbridge` reads SendGrid, Twilio and HubSpot as one object, the
176
232
  // way it did when they sat on one row — the row split, the readers did not.
@@ -194,6 +250,30 @@ const vendorSettings = async ({ controller, slug }) => {
194
250
 
195
251
  };
196
252
 
253
+ // EVERY VENDOR A CONNECTION SPENDS, SWITCHED ON.
254
+ //
255
+ // The connection-level answer to the provider-level question, and the two are
256
+ // not one-to-one in either direction: `drawbridge` spends SendGrid, Twilio and
257
+ // HubSpot, so switching any one of them off makes that connection unusable,
258
+ // while `webhook` spends no vendor at all and can never be switched off.
259
+ //
260
+ // Same walk as vendorSettings, which is deliberate — a connection that reads
261
+ // three rows as one settings object has to answer the switch across the same
262
+ // three, or the two would disagree about which vendors a connection depends on.
263
+ const vendorsEnabled = async ({ controller, slug }) => {
264
+
265
+ const declared = Object.hasOwn( connections, slug ) ? Object.keys( connections[ slug ].provider?.vendors || {} ) : [];
266
+
267
+ for( const vendor of declared ){
268
+
269
+ if( ! await vendorEnabled({ controller, vendor }) ) return false;
270
+
271
+ }
272
+
273
+ return true;
274
+
275
+ };
276
+
197
277
  // Write a vendor's credentials.
198
278
  //
199
279
  // ONLY THE DECLARED FIELDS. The body arrives over the wire, so anything in it
@@ -203,7 +283,11 @@ const vendorSettings = async ({ controller, slug }) => {
203
283
  // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
204
284
  // unchanged form posts it back empty; treating that as a clear would wipe a
205
285
  // credential every time someone edited the field next to it.
206
- const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
286
+ // SWITCHING OFF IS A SAVE, not its own route. The toggle sits in the same drawer
287
+ // as the credentials and travels with them, so an admin who turns a vendor off
288
+ // and corrects a key does it in one gesture. Omitted leaves the row's current
289
+ // answer alone, the same way a blank credential does.
290
+ const saveProviderSettings = async ({ authenticated, clear, controller, enabled, settings, slug }) => {
207
291
 
208
292
  const fields = providerFields( slug );
209
293
 
@@ -263,6 +347,7 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
263
347
  collection : 'provider',
264
348
  data : {
265
349
  $set : {
350
+ ...( enabled !== undefined && { enabled : Boolean( enabled ) }),
266
351
  settings : encrypt( merged )
267
352
  }
268
353
  },
@@ -309,7 +394,14 @@ const providerEnvNames = () => new Set(
309
394
  //
310
395
  // A field with no value is OMITTED rather than set empty, so `requires` sees
311
396
  // the same absence it would for an unset variable.
312
- const providerCredentials = async ({ controller }) => {
397
+ //
398
+ // `includeDisabled` is for VERIFYING, not spending. Switching a vendor off means
399
+ // we stop sending through it; it does not mean a message signed with its secret
400
+ // stopped being genuine. Delivery events for mail already sent still arrive, and
401
+ // a STOP reply is an obligation whether or not we are currently sending — so
402
+ // the inbound routes read the secret with the switch ignored, and everything
403
+ // that decides what a merchant can connect or a step can run reads it honoured.
404
+ const providerCredentials = async ({ controller, includeDisabled = false }) => {
313
405
 
314
406
  const credentials = {};
315
407
 
@@ -326,7 +418,7 @@ const providerCredentials = async ({ controller }) => {
326
418
  // which is exactly where someone goes to fix it.
327
419
  try {
328
420
 
329
- const settings = await providerSettings({ controller, slug });
421
+ const settings = await providerSettings({ controller, includeDisabled, slug });
330
422
 
331
423
  for( const field of providerFields( slug ) ){
332
424
 
@@ -348,4 +440,4 @@ const providerCredentials = async ({ controller }) => {
348
440
 
349
441
  };
350
442
 
351
- export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings, vendorSettings, vendors };
443
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerRow, providerSettings, providerSlugs, saveProviderSettings, vendorEnabled, vendorSettings, vendors, vendorsEnabled };