@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.
@@ -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 };
package/dist/providers.js CHANGED
@@ -494,10 +494,15 @@ var attentive_default2 = {
494
494
  "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."
495
495
  ],
496
496
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
497
+ // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
498
+ // no segment chosen sits at Pending, and a guide that stops at the consent
499
+ // leaves a merchant looking at a connection they think is broken. Same
500
+ // omission Klaviyo's guide already carries a note about.
497
501
  guide: [
498
502
  "Press Connect. Drawbridge sends you to Attentive to approve access.",
499
503
  "Sign in to Attentive if you are not already, and authorize the permissions listed.",
500
- "You are returned here \u2014 choose which Attentive segment your contacts should sync into."
504
+ "You come back here to choose which Attentive segment your contacts should sync into.",
505
+ "The connection shows Pending until you choose a segment, then Active."
501
506
  ]
502
507
  },
503
508
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
@@ -760,6 +765,9 @@ var attentive_default2 = {
760
765
  // merchant would recognise in a builder label.
761
766
  key: "Sync contact to Attentive",
762
767
  queue: "connection",
768
+ // Attentive applies the update in the background and returns no id we
769
+ // could hand on, so there is nothing for a later step to interpolate.
770
+ returns: [],
763
771
  // Nothing for a merchant to configure on the step itself — the segment
764
772
  // is chosen once on the connection. Declared empty rather than omitted,
765
773
  // so "this step takes no settings" and "nobody thought about settings"
@@ -1987,13 +1995,18 @@ var drawbridge_default2 = {
1987
1995
  request: request2,
1988
1996
  response: { count, notified: recipients.length },
1989
1997
  writes: recipients.map((member) => {
1990
- var _a2, _b2;
1998
+ var _a2, _b2, _c2;
1991
1999
  return queueNotification({
1992
2000
  audience: "member",
1993
2001
  message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
1994
2002
  organization: workflow.organization,
1995
2003
  send: { type: "email", email: member.email },
1996
- title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
2004
+ // The editor's plain rendering, interpolated the same way so
2005
+ // {{count}} resolves in the text part too. Spread rather than
2006
+ // written as null: a step saved before `text` existed has none,
2007
+ // and the send path strips the markup itself for those.
2008
+ ...((_b2 = step.settings) == null ? void 0 : _b2.text) && { text: interpolate(step.settings.text, values) },
2009
+ title: interpolate((_c2 = step.settings) == null ? void 0 : _c2.subject, values),
1997
2010
  workflow: workflow.id
1998
2011
  });
1999
2012
  })
@@ -2062,7 +2075,7 @@ var drawbridge_default2 = {
2062
2075
  // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
2063
2076
  // and what, correctly, and to refuse early when it must not send at all.
2064
2077
  send: async ({ context, step, workflow }, { canSend, read } = {}) => {
2065
- var _a, _b;
2078
+ var _a, _b, _c;
2066
2079
  const to = context == null ? void 0 : context.email;
2067
2080
  if (!to) throw new Error("No email address on context (context.email is required)");
2068
2081
  const request2 = { to };
@@ -2099,7 +2112,11 @@ var drawbridge_default2 = {
2099
2112
  message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
2100
2113
  organization: workflow.organization,
2101
2114
  send: { type: "email", email: to },
2102
- title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
2115
+ // The editor's plain rendering, so the text/plain part of this
2116
+ // email is what the merchant wrote rather than a regex's guess at
2117
+ // it. See the digest above for why it is spread, not nulled.
2118
+ ...((_b = step.settings) == null ? void 0 : _b.text) && { text: interpolate(step.settings.text, context) },
2119
+ title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
2103
2120
  workflow: workflow.id
2104
2121
  })
2105
2122
  ]
@@ -2519,6 +2536,11 @@ var drawbridge_default2 = {
2519
2536
  hook: "email.digest",
2520
2537
  key: "Email \u2014 Digest",
2521
2538
  queue: "notification",
2539
+ // Nothing for a later step to interpolate. Declared empty rather than
2540
+ // omitted, so "this step leaves nothing behind" and "nobody thought
2541
+ // about it" stay different statements — the second is what emptied the
2542
+ // builder's Variables menu for every step in the product.
2543
+ returns: [],
2522
2544
  settings: {
2523
2545
  // The organization OWNER is always a recipient, resolved by the
2524
2546
  // hook, so this is additional recipients rather than the list. It
@@ -2527,7 +2549,22 @@ var drawbridge_default2 = {
2527
2549
  // pick and could never save the step.
2528
2550
  members: { of: "string", type: "array" },
2529
2551
  message: { max: 2e3, required: true, type: "string" },
2530
- subject: { max: 150, required: true, type: "string" }
2552
+ // THE LAST SPAM CHECK, stored beside the copy it measured. Shape is
2553
+ // pinned no further than the pair that proves the match, which is the
2554
+ // only part the api reads; the rest is SpamAssassin's own output and
2555
+ // is stored as it arrives. Same declaration the campaign's draw
2556
+ // notification validates against.
2557
+ //
2558
+ // Undeclared, this was silently dropped at submit — the settings
2559
+ // validator is noUnknown().strict(), so the dashboard's step form
2560
+ // mapped the key away rather than 400 — and every open of a workflow
2561
+ // step re-ran a check against a free service to be told what the last
2562
+ // open had already been told.
2563
+ score: { shape: { message: { type: "string" }, subject: { type: "string" } }, type: "object" },
2564
+ subject: { max: 150, required: true, type: "string" },
2565
+ // The editor's plain rendering of `message`, written beside it rather
2566
+ // than reconstructed by running a regex over the markup at send time.
2567
+ text: { max: 2e3, type: "string" }
2531
2568
  },
2532
2569
  triggers: ["schedule.day", "schedule.week", "schedule.month"],
2533
2570
  usage: { actions: 0 }
@@ -2548,6 +2585,7 @@ var drawbridge_default2 = {
2548
2585
  hook: "email.notify",
2549
2586
  key: "Email \u2014 Notification",
2550
2587
  queue: "notification",
2588
+ returns: [],
2551
2589
  settings: {
2552
2590
  members: { of: "string", type: "array" },
2553
2591
  message: { max: 2e3, type: "string" },
@@ -2564,9 +2602,14 @@ var drawbridge_default2 = {
2564
2602
  hook: "email.send",
2565
2603
  key: "Email \u2014 Send email",
2566
2604
  queue: "notification",
2605
+ returns: [],
2567
2606
  settings: {
2568
2607
  message: { max: 2e3, required: true, type: "string" },
2569
- subject: { max: 150, required: true, type: "string" }
2608
+ // See email.digest above the last spam check, and the editor's
2609
+ // plain rendering of the message.
2610
+ score: { shape: { message: { type: "string" }, subject: { type: "string" } }, type: "object" },
2611
+ subject: { max: 150, required: true, type: "string" },
2612
+ text: { max: 2e3, type: "string" }
2570
2613
  },
2571
2614
  triggers: ["lead.insert"],
2572
2615
  // ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
@@ -3165,6 +3208,13 @@ var klaviyo_default2 = {
3165
3208
  // than a label that could be any of their Klaviyo accounts.
3166
3209
  key: "Sync contact to " + (((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.account) || "Klaviyo"),
3167
3210
  queue: "connection",
3211
+ // The key the hook puts in `context`, which the runner merges into the
3212
+ // run so a later step can interpolate {{klaviyoProfileId}}. Declared
3213
+ // beside the hook that writes it — see the note on Shopify's
3214
+ // commerce.code, which is where this came back from.
3215
+ returns: [
3216
+ { key: "klaviyoProfileId", label: "Klaviyo Profile ID" }
3217
+ ],
3168
3218
  // Nothing for a merchant to configure on the step itself — the list
3169
3219
  // is chosen once on the connection. Declared empty rather than
3170
3220
  // omitted, so "this step takes no settings" and "nobody thought about
@@ -3280,10 +3330,15 @@ var mailchimp_default2 = {
3280
3330
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3281
3331
  ],
3282
3332
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
3333
+ // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
3334
+ // no audience chosen sits at Pending, and a guide that stops at the consent
3335
+ // leaves a merchant looking at a connection they think is broken.
3283
3336
  guide: [
3284
3337
  "Press Connect. Drawbridge sends you to Mailchimp to approve access.",
3285
3338
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3286
- "You come back here to pick the audience your contacts should sync into."
3339
+ "You come back here to pick the audience your contacts should sync into.",
3340
+ "The connection shows Pending until you pick an audience, then Active.",
3341
+ "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3287
3342
  ]
3288
3343
  },
3289
3344
  // Mailchimp and SendGrid shared a group while they were SENDERS, where an org
@@ -3525,6 +3580,11 @@ var mailchimp_default2 = {
3525
3580
  // than showing a string like a1b2c3d4e5.
3526
3581
  key: "Sync contact to Mailchimp",
3527
3582
  queue: "connection",
3583
+ // The key the hook puts in `context`, for a later step to interpolate
3584
+ // as {{mailchimpMemberId}}. Same reasoning as Klaviyo's.
3585
+ returns: [
3586
+ { key: "mailchimpMemberId", label: "Mailchimp Member ID" }
3587
+ ],
3528
3588
  // Nothing for a merchant to configure on the step itself — the audience
3529
3589
  // is chosen once on the connection. Declared empty rather than omitted,
3530
3590
  // so "this step takes no settings" and "nobody thought about settings"
@@ -4199,8 +4259,8 @@ var shopify_default2 = {
4199
4259
  }
4200
4260
  }
4201
4261
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4202
- const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
4203
- const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
4262
+ const providerRow2 = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
4263
+ const { orderEventHandle } = (providerRow2 == null ? void 0 : providerRow2.settings) ? decrypt(providerRow2.settings) : {};
4204
4264
  const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
4205
4265
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
4206
4266
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
@@ -4805,6 +4865,24 @@ var shopify_default2 = {
4805
4865
  hook: "commerce.code",
4806
4866
  key: "Issue a discount code",
4807
4867
  queue: "connection",
4868
+ // WHAT THIS STEP LEAVES BEHIND FOR THE ONES AFTER IT, and the only
4869
+ // reason the builder can offer a Variables menu.
4870
+ //
4871
+ // These are the exact keys the hook puts in `context` — the runner
4872
+ // merges that into the run's context, and a later step interpolates
4873
+ // {{shopifyDiscountCode}} out of it. Declared beside the hook that
4874
+ // writes them so the two cannot drift; there is nowhere else that
4875
+ // knows both.
4876
+ //
4877
+ // They existed as a hand-written list in the api's workflow catalog
4878
+ // until it was derived from these manifests, and the derivation
4879
+ // hardcoded `returns : []` for every step. Nothing has offered a
4880
+ // variable since — an email step after a discount step had no way to
4881
+ // name the code it was supposed to send.
4882
+ returns: [
4883
+ { key: "shopifyDiscountCode", label: "Shopify Discount Code" },
4884
+ { key: "shopifyDiscountId", label: "Shopify Discount ID" }
4885
+ ],
4808
4886
  settings: {
4809
4887
  discount: {
4810
4888
  required: true,
@@ -4823,6 +4901,12 @@ var shopify_default2 = {
4823
4901
  hook: "commerce.customer",
4824
4902
  key: "Create customer",
4825
4903
  queue: "connection",
4904
+ // Written on every path the hook can take — created, reused from an
4905
+ // earlier step, or reused from the lead — so a later step can rely on
4906
+ // it being there whenever this one succeeded.
4907
+ returns: [
4908
+ { key: "shopifyCustomerId", label: "Shopify Customer ID" }
4909
+ ],
4826
4910
  settings: {},
4827
4911
  triggers: ["lead.insert"],
4828
4912
  usage: { actions: 1 }
@@ -5220,6 +5304,13 @@ var webhook_default = {
5220
5304
  hook: "webhook.send",
5221
5305
  key: "Send webhook",
5222
5306
  queue: "webhook",
5307
+ // The receiver's response is buffered, not merged into the run — a
5308
+ // receiver owes us a status, not a document — so there is nothing here
5309
+ // for a later step to interpolate. Declared empty rather than omitted:
5310
+ // "leaves nothing behind" and "nobody thought about it" are different
5311
+ // statements, and the second one is what emptied the builder's
5312
+ // Variables menu for every step in the product.
5313
+ returns: [],
5223
5314
  settings: {
5224
5315
  url: { format: "url", required: true, type: "string" }
5225
5316
  },
@@ -5513,6 +5604,12 @@ var publicConnectionKeys = Object.freeze([
5513
5604
  // excerpt, guide, and any vendor redirect copy.
5514
5605
  "content",
5515
5606
  "createdAt",
5607
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5608
+ // paused — kept, configured, and not run — as distinct from a vendor the
5609
+ // platform switched off (which the api overlays as an error). Absent means
5610
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5611
+ // connection the way they refuse a missing one.
5612
+ "enabled",
5516
5613
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5517
5614
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5518
5615
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -5553,17 +5650,25 @@ var mask = (value) => {
5553
5650
  var providerMemo = /* @__PURE__ */ new Map();
5554
5651
  var MEMO_TTL_MS = 60 * 1e3;
5555
5652
  var clearProviderMemo = () => providerMemo.clear();
5556
- var providerSettings = async ({ controller, slug: slug2 }) => {
5653
+ var providerRow = async ({ controller, slug: slug2 }) => {
5557
5654
  const memoized = providerMemo.get(slug2);
5558
5655
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
5559
5656
  const row = await controller.get({
5560
5657
  collection: "provider",
5561
5658
  query: { slug: slug2 }
5562
5659
  });
5563
- const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
5660
+ const value = {
5661
+ enabled: (row == null ? void 0 : row.enabled) !== false,
5662
+ settings: (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {}
5663
+ };
5564
5664
  providerMemo.set(slug2, { at: Date.now(), value });
5565
5665
  return value;
5566
5666
  };
5667
+ var providerSettings = async ({ controller, includeDisabled = false, slug: slug2 }) => {
5668
+ const row = await providerRow({ controller, slug: slug2 });
5669
+ return row.enabled || includeDisabled ? row.settings : {};
5670
+ };
5671
+ var vendorEnabled = async ({ controller, vendor }) => (await providerRow({ controller, slug: vendor })).enabled;
5567
5672
  var vendorSettings = async ({ controller, slug: slug2 }) => {
5568
5673
  var _a;
5569
5674
  const declared = Object.hasOwn(connections, slug2) ? Object.keys(((_a = connections[slug2].provider) == null ? void 0 : _a.vendors) || {}) : [];
@@ -5573,7 +5678,15 @@ var vendorSettings = async ({ controller, slug: slug2 }) => {
5573
5678
  }
5574
5679
  return merged;
5575
5680
  };
5576
- var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug: slug2 }) => {
5681
+ var vendorsEnabled = async ({ controller, slug: slug2 }) => {
5682
+ var _a;
5683
+ const declared = Object.hasOwn(connections, slug2) ? Object.keys(((_a = connections[slug2].provider) == null ? void 0 : _a.vendors) || {}) : [];
5684
+ for (const vendor of declared) {
5685
+ if (!await vendorEnabled({ controller, vendor })) return false;
5686
+ }
5687
+ return true;
5688
+ };
5689
+ var saveProviderSettings = async ({ authenticated, clear, controller, enabled, settings, slug: slug2 }) => {
5577
5690
  const fields2 = providerFields(slug2);
5578
5691
  if (!fields2.length) return null;
5579
5692
  const existing = await controller.get({
@@ -5595,6 +5708,7 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5595
5708
  collection: "provider",
5596
5709
  data: {
5597
5710
  $set: {
5711
+ ...enabled !== void 0 && { enabled: Boolean(enabled) },
5598
5712
  settings: encrypt(merged)
5599
5713
  }
5600
5714
  },
@@ -5609,11 +5723,11 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5609
5723
  var providerEnvNames = () => new Set(
5610
5724
  providerSlugs().flatMap((slug2) => providerFields(slug2)).map((field2) => field2.credential).filter(Boolean)
5611
5725
  );
5612
- var providerCredentials = async ({ controller }) => {
5726
+ var providerCredentials = async ({ controller, includeDisabled = false }) => {
5613
5727
  const credentials2 = {};
5614
5728
  for (const slug2 of providerSlugs()) {
5615
5729
  try {
5616
- const settings = await providerSettings({ controller, slug: slug2 });
5730
+ const settings = await providerSettings({ controller, includeDisabled, slug: slug2 });
5617
5731
  for (const field2 of providerFields(slug2)) {
5618
5732
  const value = settings == null ? void 0 : settings[field2.key];
5619
5733
  if (field2.credential && value) credentials2[field2.credential] = value;
@@ -5632,9 +5746,12 @@ export {
5632
5746
  providerEnvNames,
5633
5747
  providerFields,
5634
5748
  providerMemo,
5749
+ providerRow,
5635
5750
  providerSettings,
5636
5751
  providerSlugs,
5637
5752
  saveProviderSettings,
5753
+ vendorEnabled,
5638
5754
  vendorSettings,
5639
- vendors
5755
+ vendors,
5756
+ vendorsEnabled
5640
5757
  };
package/package.json CHANGED
@@ -226,5 +226,5 @@
226
226
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
227
227
  },
228
228
  "types": "dist/index.d.ts",
229
- "version": "0.0.162"
229
+ "version": "0.0.164"
230
230
  }