@drawbridge/drawbridge-utils 0.0.111 → 0.0.114

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.
@@ -13,10 +13,25 @@ var HOOKS = Object.freeze({
13
13
  // the credential can be valid and the grant still be too narrow.
14
14
  "scopes",
15
15
  // Revoke at the vendor and drop what we hold.
16
- "disconnect"
16
+ "disconnect",
17
+ // MINT A TOKEN — from a consent code, or from a stored refresh token. Both
18
+ // are the same POST, so they are one hook.
19
+ //
20
+ // The default body is authToken() in oauth.js and most vendors point
21
+ // straight at it. It is a hook rather than a declared flag because the
22
+ // vendors that differ, differ in ways config cannot express: Klaviyo needs
23
+ // HTTP Basic where others want body fields, and Mailchimp cannot use the
24
+ // token it receives until a second call tells it which data centre the
25
+ // account is behind.
26
+ "token"
17
27
  ]),
18
28
  // What happens around connecting and disconnecting, beyond the credential.
19
29
  lifecycle: Object.freeze([
30
+ // KEEP ACCESS WORKING. Rotate a credential before its window closes, prove
31
+ // it still works, and reconcile whatever the vendor has changed underneath
32
+ // — scopes, webhooks. Distinct from auth.probe, which only answers "is this
33
+ // still good": this one FIXES what it can and reports what it cannot.
34
+ "health",
20
35
  // Post-connect setup: register the vendor's webhooks, create the system
21
36
  // workflows that describe them.
22
37
  "register",
@@ -70,10 +85,83 @@ var HOOKS = Object.freeze({
70
85
  // carry them. The declaration is what proves the implementation exists.
71
86
  "process"
72
87
  ]),
73
- // Vendor data a campaign draws on. Named for what every store platform has,
74
- // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
75
- // and promotion codes, BigCommerce says coupons and promotions.
76
- catalog: Object.freeze([
88
+ // WHAT A WORKFLOW STEP DOES, as a verb like any other. Step handlers used to
89
+ // live in drawbridge-sync keyed by step type, which meant a vendor's logic sat
90
+ // in a repo the vendor file could not see — the split this whole exercise
91
+ // exists to close.
92
+ //
93
+ // A `steps` entry points at one of these; the step says when it runs and what
94
+ // it costs, the hook does the work.
95
+ contacts: Object.freeze([
96
+ // Forget a contact at the vendor. Distinct from suppression, which keeps the
97
+ // record and marks it unsubscribed: this is deletion, for account closure.
98
+ "remove",
99
+ // Push one contact into the audience the merchant chose, honouring
100
+ // suppression rather than omitting an opted-out person — omission lets them
101
+ // quietly reappear on the next sync.
102
+ "sync"
103
+ ]),
104
+ commerce: Object.freeze([
105
+ // Mint a discount code against this merchant's store, mapped to one lead.
106
+ "code",
107
+ // Create the buyer at the vendor, so an order can be attributed to them.
108
+ "customer",
109
+ // An order arrived at the vendor: attribute it, record it, update totals.
110
+ "order",
111
+ // Pull product data across on a vendor update.
112
+ "product"
113
+ ]),
114
+ // WHAT DRAWBRIDGE ITSELF DOES. These are not a third party's verbs — nobody
115
+ // connects an account to send email through Drawbridge — but they are steps a
116
+ // workflow runs, and a step points at a hook. So they live on a PRIVATE
117
+ // connection: one that contributes steps and never appears in the catalog.
118
+ //
119
+ // Without it the base steps stay the exception the shell has to know about,
120
+ // and "every step is a declaration pointing at a hook" stops being true the
121
+ // moment somebody looks at the six most common ones.
122
+ email: Object.freeze([
123
+ // To a lead. Suppression applies, and the send is billed.
124
+ "send",
125
+ // To organization members. Never suppressed — an entrant's opt-out must not
126
+ // silence an alert to staff — and never billed.
127
+ "notify",
128
+ // A batched summary to members.
129
+ "digest"
130
+ ]),
131
+ sms: Object.freeze(["send"]),
132
+ segment: Object.freeze(["sync"]),
133
+ // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
134
+ // The Webhooks connection is the only thing here with no third party behind
135
+ // it, and the destination is per STEP rather than per connection.
136
+ webhook: Object.freeze(["send"]),
137
+ // Vendor data we READ — the things a merchant picks from. Named `resources`
138
+ // rather than `catalog` because it also holds audiences, and a catalog is a
139
+ // commerce word for something that is not only commerce. It matches the
140
+ // pickers that render it, too: InputResource and ListResource.
141
+ //
142
+ // The verbs are named for what every vendor has, not for what one calls it:
143
+ // Shopify says discounts, Stripe says coupons and promotion codes,
144
+ // BigCommerce says coupons and promotions.
145
+ //
146
+ // ONE SHAPE FOR ALL OF THEM — searchable and cursor-paged:
147
+ //
148
+ // ({ connection, cursor, limit, search, settings, token })
149
+ // -> { items : [ { id, title, ... } ], pageInfo : { endCursor, hasNextPage } }
150
+ //
151
+ // Lifted from what the Shopify product picker already does, rather than
152
+ // invented: that endpoint takes cursor/limit/search and returns items plus a
153
+ // pageInfo, and ListResource consumes exactly that. It was a good contract
154
+ // written once for one vendor; this makes it the contract.
155
+ //
156
+ // `title` is not arbitrary — ListResource searches `[ 'title' ]` by default,
157
+ // so normalising to { id, title } is what lets a picker work with no
158
+ // per-vendor configuration.
159
+ //
160
+ // HOW a vendor searches is its own business, which is the point of a hook.
161
+ // Shopify pushes the term into its GraphQL query, Klaviyo has a filter
162
+ // parameter, and Mailchimp's /lists has no name filter at all so its hook
163
+ // matches against what it fetched. The caller never learns which.
164
+ resources: Object.freeze([
77
165
  // The named groups a contact can be synced INTO. Klaviyo calls them lists,
78
166
  // Mailchimp calls them audiences; `audiences` is the industry-generic term
79
167
  // and belongs to neither vendor's API. Read at form time, so a merchant
@@ -88,6 +176,37 @@ var HOOKS = Object.freeze({
88
176
  "promotions"
89
177
  ])
90
178
  });
179
+ var STEPS = Object.freeze({
180
+ "commerce.code.issue": "Issue discount code",
181
+ "commerce.customer.insert": "Create customer",
182
+ "commerce.order.record": "Record order",
183
+ "commerce.product.sync": "Sync product",
184
+ // Not commerce at all — connection lifecycle, and they generalise to any
185
+ // vendor holding a rotating credential.
186
+ "connection.health.check": "Connection health check",
187
+ "connection.token.exchange": "Exchange token",
188
+ "connection.token.refresh": "Refresh token",
189
+ "contacts.sync": "Sync contact",
190
+ "email.digest": "Digest",
191
+ "email.notify": "Notification",
192
+ "email.send": "Send email",
193
+ "segment.sync": "Sync segment",
194
+ "sms.send": "Send SMS",
195
+ "webhook.send": "Send webhook"
196
+ });
197
+ var RETIRED = Object.freeze({
198
+ "step.shopify.customer.insert": "step.commerce.customer.insert",
199
+ "step.shopify.discount.update": "step.commerce.code.issue",
200
+ "step.shopify.health.check": "step.connection.health.check",
201
+ "step.shopify.order.record": "step.commerce.order.record",
202
+ "step.shopify.product.sync": "step.commerce.product.sync",
203
+ "step.shopify.token.exchange": "step.connection.token.exchange",
204
+ "step.shopify.token.refresh": "step.connection.token.refresh"
205
+ });
206
+ var STEP_TYPES = Object.freeze(Object.keys(STEPS).map((name) => "step." + name));
207
+ var STEP_LABELS = Object.freeze(Object.fromEntries(
208
+ Object.entries(STEPS).map(([name, label]) => ["step." + name, label])
209
+ ));
91
210
  var HOOK_NAMES = Object.freeze(
92
211
  Object.entries(HOOKS).flatMap(([domain, verbs]) => verbs.map((verb) => domain + "." + verb))
93
212
  );
@@ -101,9 +220,11 @@ var OUTCOMES = Object.freeze({
101
220
  unimplemented: "unimplemented",
102
221
  unsupported: "unsupported"
103
222
  });
104
- var AUTH_TYPES = Object.freeze(["generated", "install", "keys", "oauth"]);
105
- var CATEGORIES = Object.freeze(["commerce", "contacts", "developer", "messaging"]);
106
- var OAUTH_FIELDS = Object.freeze(["authorize", "client", "redirect", "token"]);
223
+ var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
224
+ var AUTH_TYPES = Object.freeze(["generated", "install", "keys", "none", "oauth"]);
225
+ var GROUPS = Object.freeze(["commerce", "contacts", "developer", "messaging"]);
226
+ var OAUTH_FIELDS = Object.freeze(["client"]);
227
+ var OAUTH_URLS = Object.freeze(["authorize", "redirect", "token"]);
107
228
  var INPUTS = Object.freeze([
108
229
  "checkbox",
109
230
  "email",
@@ -117,12 +238,12 @@ var INPUTS = Object.freeze([
117
238
 
118
239
  // lib/connections/oauth.js
119
240
  import { createHash, randomBytes } from "crypto";
120
- var credentials = ({ clientId, clientSecret, descriptor }) => (descriptor == null ? void 0 : descriptor.clientAuth) === "basic" ? {
121
- headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") },
122
- body: {}
241
+ var credentials = ({ basic, clientId, clientSecret }) => basic ? {
242
+ body: {},
243
+ headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") }
123
244
  } : {
124
- headers: {},
125
- body: { client_id: clientId, client_secret: clientSecret }
245
+ body: { client_id: clientId, client_secret: clientSecret },
246
+ headers: {}
126
247
  };
127
248
  var pkcePair = () => {
128
249
  const verifier = randomBytes(32).toString("base64url");
@@ -133,10 +254,11 @@ var pkcePair = () => {
133
254
  };
134
255
  };
135
256
  var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
257
+ var _a;
136
258
  if (!clientId) throw new Error("This deployment has no OAuth client configured, so there is nothing to consent through");
137
- if (!(descriptor == null ? void 0 : descriptor.authorize)) throw new Error("This connection declares no authorize url");
259
+ if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.authorize)) throw new Error("This connection declares no authorize url");
138
260
  if (descriptor.pkce && !challenge) throw new Error("This connection requires PKCE, so a code challenge is not optional");
139
- return descriptor.authorize + "?" + new URLSearchParams({
261
+ return descriptor.urls.authorize + "?" + new URLSearchParams({
140
262
  // The descriptor's own params go FIRST, so a vendor quirk cannot quietly
141
263
  // overwrite one of the fields below that every consent carries.
142
264
  ...descriptor.params || {},
@@ -151,17 +273,33 @@ var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =>
151
273
  state
152
274
  });
153
275
  };
154
- var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
155
- if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
156
- if (descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
157
- const client = credentials({ clientId, clientSecret, descriptor });
158
- const response = await fetcher(descriptor.token, {
276
+ var authToken = async ({
277
+ basic,
278
+ clientId,
279
+ clientSecret,
280
+ code: code2,
281
+ descriptor,
282
+ fetcher = fetch,
283
+ redirect,
284
+ refreshToken,
285
+ verifier
286
+ } = {}) => {
287
+ var _a;
288
+ if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
289
+ if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.token)) throw new Error("This connection declares no token url");
290
+ const renewing = !code2;
291
+ if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
292
+ if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
293
+ const client = credentials({ basic, clientId, clientSecret });
294
+ const response = await fetcher(descriptor.urls.token, {
159
295
  body: new URLSearchParams({
160
296
  ...client.body,
161
- code: decodeURIComponent(String(code || "").trim()),
162
- grant_type: "authorization_code",
163
- redirect_uri: redirect,
164
- ...descriptor.pkce && { code_verifier: verifier }
297
+ ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
298
+ code: decodeURIComponent(String(code2 || "").trim()),
299
+ grant_type: "authorization_code",
300
+ redirect_uri: redirect,
301
+ ...descriptor.pkce && { code_verifier: verifier }
302
+ }
165
303
  }),
166
304
  headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
167
305
  method: "POST",
@@ -169,42 +307,26 @@ var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetc
169
307
  });
170
308
  const body = await response.json().catch(() => ({}));
171
309
  if (!response.ok) {
172
- throw new Error("The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : ""));
310
+ throw new Error(
311
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
312
+ );
173
313
  }
174
- if (!body.access_token) throw new Error("The vendor returned no access token");
314
+ if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
175
315
  return {
176
316
  accessToken: body.access_token,
177
317
  expiresIn: body.expires_in || null,
318
+ // A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
319
+ // access_type=offline; Mailchimp's tokens do not expire and it returns none
320
+ // at all, so its absence cannot be an error here.
321
+ //
322
+ // On a RENEWAL the same null matters for the opposite reason: a vendor that
323
+ // rotates returns a new one, and dropping it silently invalidates the stored
324
+ // grant on the NEXT refresh — a failure a day late and nowhere near its
325
+ // cause. tokenSettings() keeps the existing one when this is null.
178
326
  refreshToken: body.refresh_token || null,
179
327
  scope: body.scope || null
180
328
  };
181
329
  };
182
- var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
183
- if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
184
- if (!refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
185
- if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
186
- const client = credentials({ clientId, clientSecret, descriptor });
187
- const response = await fetcher(descriptor.token, {
188
- body: new URLSearchParams({
189
- ...client.body,
190
- grant_type: "refresh_token",
191
- refresh_token: refreshToken
192
- }),
193
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
194
- method: "POST",
195
- signal: AbortSignal.timeout(15e3)
196
- });
197
- if (!response.ok) throw new Error("The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection");
198
- const body = await response.json();
199
- return {
200
- accessToken: body.access_token,
201
- expiresIn: body.expires_in || null,
202
- // A vendor that rotates its refresh token returns a new one, and dropping
203
- // it silently invalidates the stored grant on the NEXT refresh rather
204
- // than this one — a failure a day late and nowhere near its cause.
205
- refreshToken: body.refresh_token || null
206
- };
207
- };
208
330
 
209
331
  // lib/connections/token.js
210
332
  var SKEW_SECONDS = 120;
@@ -244,7 +366,7 @@ var accessToken = async ({
244
366
  if (!settings.refreshToken) {
245
367
  throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
246
368
  }
247
- const minted = await refresh({
369
+ const minted = await manifest.hooks.auth.token({
248
370
  clientId,
249
371
  clientSecret,
250
372
  descriptor: manifest.auth.oauth,
@@ -256,6 +378,1019 @@ var accessToken = async ({
256
378
  return next.accessToken;
257
379
  };
258
380
 
381
+ // lib/connections/icons/drawbridge.js
382
+ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
383
+ <rect width="500" height="500" fill="#BAEC5F"/>
384
+ <g clip-path="url(#clip0_2115_2832)">
385
+ <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"/>
386
+ <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"/>
387
+ </g>
388
+ <defs>
389
+ <clipPath id="clip0_2115_2832">
390
+ <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
391
+ </clipPath>
392
+ </defs>
393
+ </svg>`;
394
+
395
+ // lib/features.js
396
+ var page = {
397
+ qrcode: {
398
+ key: "page:qrcode",
399
+ error: "Plan does not include qrcodes",
400
+ feature: "Page qrcode management"
401
+ },
402
+ shortcode: {
403
+ key: "page:shortcode",
404
+ error: "Plan does not include shortcodes",
405
+ feature: "Page shortcode management"
406
+ },
407
+ slug: {
408
+ key: "page:slug",
409
+ error: "Plan does not include url customization",
410
+ feature: "Page slug customization"
411
+ }
412
+ };
413
+ var fields = {
414
+ additional: {
415
+ key: "campaign:fields:additional",
416
+ error: "Plan does not include additional fields",
417
+ feature: "Campaign additional fields"
418
+ },
419
+ lead: {
420
+ key: "campaign:fields:lead",
421
+ error: "Plan does not include lead fields",
422
+ feature: "Campaign lead fields"
423
+ }
424
+ };
425
+ var field = {
426
+ email: {
427
+ key: "campaign:field:email",
428
+ error: "Plan does not include email field",
429
+ feature: "Campaign email field"
430
+ },
431
+ name: {
432
+ key: "campaign:field:name",
433
+ error: "Plan does not include name field",
434
+ feature: "Campaign name field"
435
+ },
436
+ number: {
437
+ key: "campaign:field:number",
438
+ error: "Plan does not include number field",
439
+ feature: "Campaign number field"
440
+ },
441
+ phone: {
442
+ key: "campaign:field:phone",
443
+ error: "Plan does not include phone field",
444
+ feature: "Campaign phone field"
445
+ },
446
+ select: {
447
+ key: "campaign:field:select",
448
+ error: "Plan does not include dropdown field",
449
+ feature: "Campaign dropdown field"
450
+ },
451
+ text: {
452
+ key: "campaign:field:text",
453
+ error: "Plan does not include short text field",
454
+ feature: "Campaign short text field"
455
+ },
456
+ textarea: {
457
+ key: "campaign:field:textarea",
458
+ error: "Plan does not include long text field",
459
+ feature: "Campaign long text field"
460
+ }
461
+ };
462
+ var connection = {
463
+ mailchimp: {
464
+ key: "organization:connection:mailchimp",
465
+ error: "Plan does not include Mailchimp connection",
466
+ feature: "Mailchimp connection"
467
+ },
468
+ sendgrid: {
469
+ key: "organization:connection:sendgrid",
470
+ error: "Plan does not include SendGrid connection",
471
+ feature: "SendGrid connection"
472
+ },
473
+ shopify: {
474
+ key: "organization:connection:shopify",
475
+ error: "Plan does not include Shopify connection",
476
+ feature: "Shopify connection"
477
+ },
478
+ twilio: {
479
+ key: "organization:connection:twilio",
480
+ error: "Plan does not include Twilio connection",
481
+ feature: "Twilio connection"
482
+ },
483
+ webhook: {
484
+ key: "organization:connection:webhook",
485
+ error: "Plan does not include Webhook connection",
486
+ feature: "Webhook connection"
487
+ }
488
+ };
489
+ var organization = {
490
+ advertisements: {
491
+ key: "organization:advertisements",
492
+ error: "Your plan does not include advertisements",
493
+ feature: "Organization advertisement management"
494
+ },
495
+ affiliates: {
496
+ key: "organization:affiliates",
497
+ error: "Your plan does not include affiliates",
498
+ feature: "Organization affiliates management"
499
+ },
500
+ analytics: {
501
+ key: "organization:analytics",
502
+ error: "Your plan does not include analytics",
503
+ feature: "Organization analytics management"
504
+ },
505
+ brands: {
506
+ key: "organization:brands",
507
+ error: "Your plan does not include brands",
508
+ feature: "Organization brands management"
509
+ },
510
+ // Gates the Networking section as a whole — a verified sending domain today,
511
+ // the SMS number and a custom page domain as they land. One key rather than
512
+ // one per type: they are the same capability to a merchant, and splitting
513
+ // them would mean a plan could grant half a section.
514
+ //
515
+ // It replaces `connection.sender`, which named a connection this stopped
516
+ // being. Free organizations cannot send lead-facing email at all, so a
517
+ // sending identity there is one they could never send from.
518
+ networking: {
519
+ key: "organization:networking",
520
+ error: "Your plan does not include a custom sending identity",
521
+ feature: "Organization networking"
522
+ },
523
+ members: {
524
+ key: "organization:members",
525
+ error: "Your plan does not include team members",
526
+ feature: "Organization members management"
527
+ },
528
+ reports: {
529
+ key: "organization:report",
530
+ error: "Plan does not include report generation",
531
+ feature: "Organization report generation"
532
+ },
533
+ subdomain: {
534
+ key: "organization:subdomain",
535
+ error: "Plan does not include subdomain customization",
536
+ feature: "Organization subdomain customization"
537
+ }
538
+ };
539
+
540
+ // index.js
541
+ import { code, data } from "currency-codes";
542
+ import { customAlphabet } from "nanoid";
543
+
544
+ // lib/color.js
545
+ import tinycolor from "tinycolor2";
546
+ var colorFormatted = (value) => {
547
+ const color = tinycolor(value);
548
+ const attributes = {
549
+ brightness: color.getBrightness(),
550
+ dark: color.isDark(),
551
+ light: color.isLight(),
552
+ luminance: color.getLuminance()
553
+ };
554
+ return {
555
+ attributes,
556
+ hex: color.toHexString(),
557
+ hsl: color.toHsl(),
558
+ hsv: color.toHsv(),
559
+ rgb: color.toRgbString()
560
+ };
561
+ };
562
+ var colorAccessible = (background2) => {
563
+ const white = "#ffffff";
564
+ const black = "#000000";
565
+ return tinycolor.isReadable(
566
+ background2,
567
+ white,
568
+ {
569
+ level: "AA",
570
+ size: "normal"
571
+ }
572
+ ) ? white : black;
573
+ };
574
+
575
+ // lib/constants.js
576
+ var font = {
577
+ family: "Roboto Flex",
578
+ transform: "none",
579
+ weight: "regular"
580
+ };
581
+ var background = "#ffffff";
582
+ var style = {
583
+ background: {
584
+ color: colorFormatted(background)
585
+ },
586
+ body: font,
587
+ button: {
588
+ background: {
589
+ color: colorFormatted(background)
590
+ },
591
+ radius: 0,
592
+ text: {
593
+ color: colorFormatted(colorAccessible(background))
594
+ }
595
+ },
596
+ heading: font,
597
+ input: {
598
+ radius: 0
599
+ },
600
+ text: {
601
+ color: colorFormatted(colorAccessible(background))
602
+ }
603
+ };
604
+
605
+ // index.js
606
+ var nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
607
+ var infinite = 1e300;
608
+ var megabyte = 1024 * 1024;
609
+ var gigabyte = megabyte * 1024;
610
+ var currencies = data.map((item) => ({
611
+ ...item,
612
+ key: item.currency,
613
+ value: item.code
614
+ }));
615
+
616
+ // lib/plans.js
617
+ var featuresFor = (array = []) => Object.values({
618
+ ...connection,
619
+ ...organization,
620
+ ...fields,
621
+ ...field,
622
+ ...page
623
+ }).reduce(
624
+ (accumulator, { key, error, feature }) => {
625
+ if (array.includes(key)) {
626
+ accumulator.granted[key] = feature;
627
+ } else {
628
+ accumulator.denied[key] = error;
629
+ }
630
+ return accumulator;
631
+ },
632
+ { denied: {}, granted: {} }
633
+ );
634
+ var overage = (actionCents) => ({
635
+ actionCents,
636
+ overages: { actions: String(actionCents) }
637
+ });
638
+ var all = {
639
+ features: (array = []) => featuresFor([
640
+ connection.mailchimp.key,
641
+ connection.sendgrid.key,
642
+ connection.shopify.key,
643
+ connection.twilio.key,
644
+ connection.webhook.key,
645
+ organization.affiliates.key,
646
+ organization.brands.key,
647
+ fields.additional.key,
648
+ fields.lead.key,
649
+ field.email.key,
650
+ field.name.key,
651
+ field.number.key,
652
+ field.phone.key,
653
+ field.select.key,
654
+ field.text.key,
655
+ field.textarea.key,
656
+ page.qrcode.key,
657
+ page.shortcode.key,
658
+ ...array
659
+ ]),
660
+ // `members` and `storage` default to infinite so an unnamed term on a custom
661
+ // plan reads as UNLIMITED rather than absent. Storage used to have no
662
+ // default, so a deal that did not name it resolved to undefined and the
663
+ // organization's usage card simply omitted the row — the same blank field
664
+ // that showed "Unlimited" for members showed nothing at all for storage.
665
+ // Every catalog plan names both, so the defaults only ever apply to a
666
+ // custom plan. `actions` has no default on purpose: an unnamed allowance
667
+ // bills nothing, which is why the availability switch refuses to flip
668
+ // without one.
669
+ limits: ({ actions, members = infinite, storage = infinite }) => ({
670
+ campaign: {
671
+ advertisements: infinite,
672
+ links: infinite,
673
+ fields: infinite,
674
+ pages: infinite
675
+ },
676
+ organization: {
677
+ actions,
678
+ affiliates: infinite,
679
+ brands: infinite,
680
+ campaigns: infinite,
681
+ members,
682
+ storage
683
+ }
684
+ })
685
+ };
686
+ var free = {
687
+ conversion: 3,
688
+ features: all.features(),
689
+ limits: all.limits({
690
+ actions: 200,
691
+ members: 0,
692
+ storage: gigabyte * 5
693
+ }),
694
+ title: "Free"
695
+ };
696
+ var plans = {
697
+ DB00002: {
698
+ // A verified sending domain is a PAID capability: free plans cannot send
699
+ // lead-facing email at all (the send path gates on an active
700
+ // subscription), so granting it there would offer a domain that can
701
+ // never send from.
702
+ features: all.features([organization.networking.key, organization.members.key]),
703
+ limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
704
+ marketing: {
705
+ description: "Tools to fine-tune campaigns and improve lead quality.",
706
+ features: [],
707
+ limits: [
708
+ ["Actions per month", "5,000"],
709
+ ["Affiliates", "Unlimited"],
710
+ ["Brands", "Unlimited"],
711
+ ["Campaigns", "Unlimited"],
712
+ ["Pages", "Unlimited"],
713
+ ["Members", "3"],
714
+ ["Storage", "10GB"]
715
+ ]
716
+ },
717
+ ...overage(2.5),
718
+ title: "Starter",
719
+ conversion: 2
720
+ },
721
+ DB00003: {
722
+ features: all.features([
723
+ organization.networking.key,
724
+ organization.advertisements.key,
725
+ organization.analytics.key,
726
+ organization.members.key,
727
+ organization.subdomain.key,
728
+ page.slug.key
729
+ ]),
730
+ limits: all.limits({ actions: 15e3, members: 5, storage: gigabyte * 20 }),
731
+ marketing: {
732
+ description: "Expand your reach and grow your lead pipeline.",
733
+ features: [
734
+ "Analytics",
735
+ "Custom subdomain / URLs",
736
+ "Confirmation page ads"
737
+ ],
738
+ limits: [
739
+ ["Actions per month", "15,000"],
740
+ ["Affiliates", "Unlimited"],
741
+ ["Brands", "Unlimited"],
742
+ ["Campaigns", "Unlimited"],
743
+ ["Pages", "Unlimited"],
744
+ ["Members", "5"],
745
+ ["Storage", "20GB"]
746
+ ]
747
+ },
748
+ ...overage(2),
749
+ title: "Pro",
750
+ conversion: 1.5
751
+ },
752
+ DB00004: {
753
+ features: all.features([
754
+ organization.networking.key,
755
+ organization.advertisements.key,
756
+ organization.analytics.key,
757
+ organization.members.key,
758
+ organization.subdomain.key,
759
+ page.slug.key
760
+ ]),
761
+ limits: all.limits({ actions: 4e4, members: 10, storage: gigabyte * 50 }),
762
+ marketing: {
763
+ description: "Accelerate acquisition with more power and flexibility.",
764
+ features: [
765
+ "Analytics",
766
+ "Custom subdomain / URLs",
767
+ "Confirmation page ads"
768
+ ],
769
+ limits: [
770
+ ["Actions per month", "40,000"],
771
+ ["Affiliates", "Unlimited"],
772
+ ["Brands", "Unlimited"],
773
+ ["Campaigns", "Unlimited"],
774
+ ["Pages", "Unlimited"],
775
+ ["Members", "10"],
776
+ ["Storage", "50GB"]
777
+ ]
778
+ },
779
+ ...overage(1.85),
780
+ title: "Premium",
781
+ conversion: 1
782
+ },
783
+ DB00005: {
784
+ features: all.features([
785
+ organization.networking.key,
786
+ organization.advertisements.key,
787
+ organization.analytics.key,
788
+ organization.members.key,
789
+ organization.subdomain.key,
790
+ page.slug.key
791
+ ]),
792
+ limits: all.limits({ actions: 1e5, members: infinite, storage: gigabyte * 100 }),
793
+ marketing: {
794
+ description: "Built for brands focused on results.",
795
+ features: [
796
+ "Analytics",
797
+ "Custom subdomain / URLs",
798
+ "Confirmation page ads"
799
+ ],
800
+ limits: [
801
+ ["Actions per month", "100,000"],
802
+ ["Affiliates", "Unlimited"],
803
+ ["Brands", "Unlimited"],
804
+ ["Campaigns", "Unlimited"],
805
+ ["Pages", "Unlimited"],
806
+ ["Members", "Unlimited"],
807
+ ["Storage", "100GB"]
808
+ ]
809
+ },
810
+ ...overage(1.5),
811
+ title: "Elite",
812
+ conversion: 0.5
813
+ }
814
+ };
815
+
816
+ // lib/transactions.js
817
+ import { currentTraceId } from "@drawbridge/drawbridge-telemetry";
818
+
819
+ // lib/billing.js
820
+ import { createLogger } from "@drawbridge/drawbridge-telemetry";
821
+ var logger = createLogger();
822
+ var MARKUP = 1.3;
823
+ var cost = {
824
+ // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
825
+ // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
826
+ // output). ~3.6x the retired 2.5-flash output rate.
827
+ "gemini-3.5-flash": {
828
+ cached: 15,
829
+ input: 150,
830
+ output: 900
831
+ },
832
+ // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
833
+ // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
834
+ // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
835
+ // ranks the feed on this tier — one call per page — so its rows were the
836
+ // unpriced ones until now.
837
+ "gemini-3.5-flash-lite": {
838
+ cached: 3,
839
+ input: 30,
840
+ output: 250
841
+ },
842
+ "gemini-2.5-flash": {
843
+ cached: 3,
844
+ input: 30,
845
+ output: 250
846
+ },
847
+ "gemini-2.5-flash-image": {
848
+ cached: 3,
849
+ input: 30,
850
+ output: 3e3
851
+ },
852
+ // gemini-3-pro-image-preview — verified against Google's pricing page
853
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
854
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
855
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
856
+ // that reproduces the per-image price from the tokens usageMetadata
857
+ // reports. Growth's hero generation runs this model today.
858
+ "gemini-3-pro-image-preview": {
859
+ cached: 20,
860
+ input: 200,
861
+ output: 12e3
862
+ }
863
+ };
864
+ var toolCost = {
865
+ search: 3.5
866
+ };
867
+ var toolPricing = Object.fromEntries(
868
+ Object.entries(toolCost).map(([tool, value]) => [
869
+ tool,
870
+ Math.ceil(value * MARKUP)
871
+ ])
872
+ );
873
+ var pricing = Object.fromEntries(
874
+ Object.entries(cost).map(([model, rates]) => [
875
+ model,
876
+ {
877
+ cached: Math.round(rates.cached * MARKUP),
878
+ input: Math.round(rates.input * MARKUP),
879
+ output: Math.round(rates.output * MARKUP)
880
+ }
881
+ ])
882
+ );
883
+
884
+ // lib/pricing.js
885
+ var emailPlans = {
886
+ essentials50k: {
887
+ included: 5e4,
888
+ // The plan's own price is not shown in the console once you are on it —
889
+ // it reads "Your Current Plan" where the price would be. Left null rather
890
+ // than guessed; fill from an invoice. Nothing derives from it yet, and an
891
+ // invented figure would quietly become the basis of an upgrade decision.
892
+ monthly: null,
893
+ overageCents: 0.133,
894
+ title: "Essentials 50K"
895
+ },
896
+ essentials100k: {
897
+ included: 1e5,
898
+ monthly: 3495,
899
+ overageCents: 0.09,
900
+ title: "Essentials 100K"
901
+ },
902
+ pro100k: {
903
+ included: 1e5,
904
+ monthly: 8995,
905
+ overageCents: 0.11,
906
+ title: "Pro 100K"
907
+ },
908
+ pro300k: {
909
+ included: 3e5,
910
+ monthly: 24900,
911
+ overageCents: 0.091,
912
+ title: "Pro 300K"
913
+ },
914
+ pro700k: {
915
+ included: 7e5,
916
+ monthly: 49900,
917
+ overageCents: 0.078,
918
+ title: "Pro 700K"
919
+ },
920
+ pro1500k: {
921
+ included: 15e5,
922
+ monthly: 79900,
923
+ overageCents: 0.059,
924
+ title: "Pro 1.5 Million"
925
+ },
926
+ pro2500k: {
927
+ included: 25e5,
928
+ monthly: 109900,
929
+ overageCents: 0.047,
930
+ title: "Pro 2.5 Million"
931
+ }
932
+ };
933
+ var emailPlan = "essentials50k";
934
+ var sending = {
935
+ email: {
936
+ // Fraction of the included volume that raises the alarm. Far enough ahead
937
+ // to act on, high enough not to fire on ordinary growth.
938
+ //
939
+ // Crossing it is NOT an outage. The provider bills overage rather than
940
+ // stopping sends, so the alarm is a COST signal: past the included volume
941
+ // every email is charged at overageCents, and at some run rate the next
942
+ // plan up is cheaper than the overage. That is the decision it exists to
943
+ // prompt, and it is why nobody should be woken by it.
944
+ alertThreshold: 0.8,
945
+ // Emails per calendar month included, PLATFORM-WIDE and across every send
946
+ // path: lead-facing mail, workflow steps, and the sign-in codes and
947
+ // account mail that no plan allowance meters.
948
+ monthlyCeiling: emailPlans[emailPlan].included,
949
+ overageCents: emailPlans[emailPlan].overageCents,
950
+ plan: emailPlan,
951
+ plans: emailPlans,
952
+ title: emailPlans[emailPlan].title
953
+ }
954
+ };
955
+ var channels = {
956
+ email: {
957
+ actionsPerSend: 1,
958
+ includedInAllowance: true
959
+ },
960
+ sms: {
961
+ // Two actions PER SEGMENT (a long message is several segments), billed from
962
+ // the FIRST segment and never drawn from the plan's included allowance —
963
+ // carrier cost is real from message one, so there is no free tier of it.
964
+ actionsPerSegment: 2,
965
+ includedInAllowance: false
966
+ }
967
+ };
968
+
969
+ // lib/connections/drawbridge.js
970
+ var drawbridge_default2 = {
971
+ auth: {
972
+ type: "none"
973
+ },
974
+ content: {
975
+ confirm: "This connection is part of Drawbridge and cannot be disconnected.",
976
+ description: [
977
+ "Drawbridge sends your notification email and SMS, keeps your segments in sync, and posts to your own endpoints. These are built in rather than connected, so there is nothing here to set up."
978
+ ],
979
+ excerpt: "The steps Drawbridge runs itself.",
980
+ guide: [
981
+ "Nothing to do. These steps are available in every workflow builder."
982
+ ]
983
+ },
984
+ exclusive: false,
985
+ fields: [],
986
+ group: "developer",
987
+ // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
988
+ // suppression collection and the queues; segment sync needs the streams. A
989
+ // published package carrying those makes every consumer carry them, which is
990
+ // the reason `{}` exists as an answer.
991
+ hooks: {
992
+ auth: {
993
+ // Nothing to connect, revoke, probe or re-scope.
994
+ connect: false,
995
+ disconnect: false,
996
+ probe: false,
997
+ scopes: false,
998
+ token: false
999
+ },
1000
+ commerce: false,
1001
+ contacts: { remove: false, sync: false },
1002
+ email: {
1003
+ digest: {},
1004
+ // To organization members. NEVER suppressed and never billed: an
1005
+ // entrant's opt-out must not silence an alert to staff, and staff mail is
1006
+ // not a metered send.
1007
+ notify: {},
1008
+ // To a lead. Suppression applies and the send is billed.
1009
+ send: {}
1010
+ },
1011
+ inbound: false,
1012
+ lifecycle: false,
1013
+ resources: {
1014
+ audiences: false,
1015
+ prices: false,
1016
+ products: false,
1017
+ promotions: false
1018
+ },
1019
+ segment: { sync: {} },
1020
+ sms: { send: {} },
1021
+ webhook: false
1022
+ },
1023
+ icon: drawbridge_default,
1024
+ // PRIVATE: never in the catalog, always available to the builder.
1025
+ private: true,
1026
+ requires: [],
1027
+ slug: "drawbridge",
1028
+ // Always on. There is no credential that could go bad and no configuration a
1029
+ // merchant could leave half-finished.
1030
+ status: () => "active",
1031
+ // DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
1032
+ // below — trigger, billable, settings — is what that catalog and the workflow
1033
+ // route already enforce today, because this replaces them rather than
1034
+ // competing with them.
1035
+ //
1036
+ // NOT HERE, deliberately:
1037
+ //
1038
+ // step.segment.sync a SYSTEM step, dispatched by drawbridge-sync rather
1039
+ // than offered in the builder. It fans out, so the shell
1040
+ // opens its step document and the chunks close it.
1041
+ steps: {
1042
+ email: {
1043
+ // SCHEDULE-TRIGGERED, not lead-triggered: it is offered under Daily,
1044
+ // Weekly and Monthly. Those triggers had offered no steps at all, so a
1045
+ // scheduled workflow was selectable and inert until this landed.
1046
+ digest: () => ({
1047
+ hook: "email.digest",
1048
+ key: "Email \u2014 Digest",
1049
+ queue: "notification",
1050
+ settings: {
1051
+ // The organization OWNER is always a recipient, resolved in sync,
1052
+ // so this is additional recipients rather than the list. It cannot
1053
+ // be required: the members endpoint is owner-gated and the owner is
1054
+ // not a member document, so a solo merchant has nothing to pick and
1055
+ // could never save the step.
1056
+ members: { of: "string", type: "array" },
1057
+ message: { required: true, type: "string" },
1058
+ subject: { required: true, type: "string" }
1059
+ },
1060
+ triggers: ["schedule.day", "schedule.week", "schedule.month"],
1061
+ usage: { actions: 0 }
1062
+ }),
1063
+ // To organization MEMBERS. Never suppressed — an entrant's opt-out must
1064
+ // not silence an alert to staff — and not billed.
1065
+ notify: () => ({
1066
+ hook: "email.notify",
1067
+ key: "Email \u2014 Notification",
1068
+ queue: "notification",
1069
+ settings: {
1070
+ members: { of: "string", type: "array" },
1071
+ message: { required: true, type: "string" },
1072
+ subject: { required: true, type: "string" }
1073
+ },
1074
+ triggers: ["lead.insert"],
1075
+ // Zero is a PRICE, and a deliberate one. Declared rather than omitted
1076
+ // so "this is free" and "nobody decided" stay different statements;
1077
+ // completeStep bills only when actions > 0.
1078
+ usage: { actions: 0 }
1079
+ }),
1080
+ // To a LEAD. Suppression applies and the send is billed.
1081
+ send: () => ({
1082
+ hook: "email.send",
1083
+ key: "Email \u2014 Send email",
1084
+ queue: "notification",
1085
+ settings: {
1086
+ message: { required: true, type: "string" },
1087
+ subject: { required: true, type: "string" }
1088
+ },
1089
+ triggers: ["lead.insert"],
1090
+ // ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
1091
+ // customer-facing number; the handler read it too, so the same fact
1092
+ // was stated in two places and only one of them was reviewed.
1093
+ usage: { actions: channels.email.actionsPerSend }
1094
+ })
1095
+ },
1096
+ // WITHDRAWN, which is a third thing from builder and system: declared,
1097
+ // routed and runnable, but never offered.
1098
+ //
1099
+ // It went when the twilio connection did — a connection-gated step with no
1100
+ // connection to gate on could only ever render permanently disabled. Stored
1101
+ // workflows still carry it, so it must keep running, and enums.step.type
1102
+ // keeps it for the same reason.
1103
+ //
1104
+ // NO TRIGGERS is what keeps it out of the builder: the catalog derives from
1105
+ // triggers, so a step with none is unreachable by a merchant without a
1106
+ // second list saying so.
1107
+ //
1108
+ // Platform SMS returns as a base step the way email did. That is this entry
1109
+ // gaining triggers, not a new one.
1110
+ sms: {
1111
+ send: () => ({
1112
+ hook: "sms.send",
1113
+ key: "Send an SMS",
1114
+ queue: "notification",
1115
+ settings: {
1116
+ message: { required: true, type: "string" },
1117
+ subject: { required: true, type: "string" }
1118
+ },
1119
+ // Priced per SEGMENT and billed from the first, which the send
1120
+ // resolves from the message length. This is the floor.
1121
+ usage: { actions: channels.sms.actionsPerSegment },
1122
+ withdrawn: true
1123
+ })
1124
+ },
1125
+ segment: {
1126
+ // FANS OUT. It evaluates every contact in the organization against every
1127
+ // segment, which is too much for one job — so the hook returns chunks and
1128
+ // the shell defers completion: openStep writes the document with a slot
1129
+ // per chunk, and whichever chunk lands last closes it and resumes the
1130
+ // chain.
1131
+ //
1132
+ // It carries a hook like every other step. An earlier version declared
1133
+ // none, on the theory that fan-out was a second protocol the shell could
1134
+ // not run; it is the same protocol with the ending deferred, and a step
1135
+ // declaring no hook is silently SKIPPED by the runner.
1136
+ sync: () => ({
1137
+ description: "Recalculates segment membership on a daily schedule.",
1138
+ hook: "segment.sync",
1139
+ key: "Segment Sync",
1140
+ queue: "segment",
1141
+ system: true
1142
+ })
1143
+ }
1144
+ },
1145
+ tasks: () => [],
1146
+ title: "Drawbridge"
1147
+ };
1148
+
1149
+ // lib/http.js
1150
+ var DEFAULT_TIMEOUT_MS = 15e3;
1151
+ var request = async ({
1152
+ body,
1153
+ headers = {},
1154
+ method = "GET",
1155
+ query,
1156
+ timeout = DEFAULT_TIMEOUT_MS,
1157
+ type = "json",
1158
+ url
1159
+ }) => {
1160
+ const fullUrl = new URL(url);
1161
+ if (query) {
1162
+ Object.entries(query).forEach(([k, v]) => fullUrl.searchParams.set(k, v));
1163
+ }
1164
+ ;
1165
+ const isForm = type === "form";
1166
+ const response = await fetch(fullUrl.toString(), {
1167
+ method,
1168
+ headers: {
1169
+ "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
1170
+ ...headers
1171
+ },
1172
+ signal: AbortSignal.timeout(timeout),
1173
+ ...body !== void 0 && {
1174
+ body: isForm ? new URLSearchParams(body).toString() : JSON.stringify(body)
1175
+ }
1176
+ });
1177
+ if (!response.ok) {
1178
+ const text2 = await response.text().catch(() => "");
1179
+ const error = new Error(text2 || response.statusText);
1180
+ error.status = response.status;
1181
+ throw error;
1182
+ }
1183
+ ;
1184
+ const text = await response.text();
1185
+ try {
1186
+ return text ? JSON.parse(text) : null;
1187
+ } catch {
1188
+ return null;
1189
+ }
1190
+ };
1191
+
1192
+ // lib/connections/hubspot.js
1193
+ var HUBSPOT_BASE = "https://api.hubapi.com";
1194
+ var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
1195
+ return (fetcher || request)({
1196
+ body,
1197
+ headers: {
1198
+ "Authorization": "Bearer " + (token || process.env.HUBSPOT_ACCESS_TOKEN)
1199
+ },
1200
+ method,
1201
+ query,
1202
+ url: HUBSPOT_BASE + path
1203
+ });
1204
+ };
1205
+ var UTM_PROPERTIES = {
1206
+ campaign: "utm_campaign",
1207
+ content: "utm_content",
1208
+ id: "utm_id",
1209
+ medium: "utm_medium",
1210
+ source: "utm_source",
1211
+ term: "utm_term"
1212
+ };
1213
+ var CLICK_PROPERTIES = {
1214
+ fbclid: "hs_facebook_click_id",
1215
+ gclid: "hs_google_click_id",
1216
+ liFatId: "hs_linkedin_click_id",
1217
+ msclkid: "hs_bing_click_id",
1218
+ ttclid: "hs_tiktok_click_id"
1219
+ };
1220
+ var DROPPABLE = new Set(Object.values(UTM_PROPERTIES));
1221
+ var isUtmProperty = (key) => DROPPABLE.has(key);
1222
+ var toProperties = ({ email, firstName, lastName, utm }) => {
1223
+ var _a;
1224
+ const properties = {};
1225
+ if (email !== void 0) properties.email = email;
1226
+ if (firstName !== void 0) properties.firstname = firstName;
1227
+ if (lastName !== void 0) properties.lastname = lastName;
1228
+ if (utm) {
1229
+ for (const [key, property] of Object.entries(UTM_PROPERTIES)) {
1230
+ if (utm[key]) properties[property] = utm[key];
1231
+ }
1232
+ ;
1233
+ for (const [key, property] of Object.entries(CLICK_PROPERTIES)) {
1234
+ if ((_a = utm.click) == null ? void 0 : _a[key]) properties[property] = utm.click[key];
1235
+ }
1236
+ ;
1237
+ }
1238
+ ;
1239
+ return properties;
1240
+ };
1241
+ var send = async ({ doc, fetcher, method, path, token }) => {
1242
+ const properties = toProperties(doc);
1243
+ try {
1244
+ return await hubspotRequest({
1245
+ body: { properties },
1246
+ fetcher,
1247
+ method,
1248
+ path,
1249
+ token
1250
+ });
1251
+ } catch (error) {
1252
+ const enriched = Object.keys(properties).some(isUtmProperty);
1253
+ if ((error == null ? void 0 : error.status) !== 400 || !enriched) throw error;
1254
+ return await hubspotRequest({
1255
+ body: {
1256
+ properties: Object.fromEntries(
1257
+ Object.entries(properties).filter(([key]) => !isUtmProperty(key))
1258
+ )
1259
+ },
1260
+ fetcher,
1261
+ method,
1262
+ path,
1263
+ token
1264
+ });
1265
+ }
1266
+ };
1267
+ var lookup = async ({ email, fetcher, token }) => {
1268
+ var _a, _b;
1269
+ if (!token || !email) return;
1270
+ try {
1271
+ const body = await hubspotRequest({
1272
+ body: {
1273
+ filterGroups: [
1274
+ {
1275
+ filters: [
1276
+ {
1277
+ operator: "EQ",
1278
+ propertyName: "email",
1279
+ value: email
1280
+ }
1281
+ ]
1282
+ }
1283
+ ],
1284
+ limit: 1,
1285
+ properties: ["email"]
1286
+ },
1287
+ fetcher,
1288
+ method: "POST",
1289
+ path: "/crm/v3/objects/contacts/search",
1290
+ token
1291
+ });
1292
+ return (_b = (_a = body == null ? void 0 : body.results) == null ? void 0 : _a[0]) == null ? void 0 : _b.id;
1293
+ } catch (error) {
1294
+ }
1295
+ };
1296
+ var hubspot_default = {
1297
+ auth: {
1298
+ // A Private App token from our own portal. Nothing to connect, nothing to
1299
+ // consent to, and no merchant involved.
1300
+ type: "none"
1301
+ },
1302
+ content: {
1303
+ confirm: "This connection is part of Drawbridge and cannot be disconnected.",
1304
+ description: [
1305
+ "Drawbridge keeps its own HubSpot portal in step with account signups, so the campaign a customer arrived on is on their contact record."
1306
+ ],
1307
+ excerpt: "Drawbridge's own CRM sync.",
1308
+ guide: [
1309
+ "Nothing to do. This is internal to Drawbridge."
1310
+ ]
1311
+ },
1312
+ exclusive: false,
1313
+ fields: [],
1314
+ group: "contacts",
1315
+ hooks: {
1316
+ auth: {
1317
+ connect: false,
1318
+ disconnect: false,
1319
+ probe: false,
1320
+ scopes: false,
1321
+ token: false
1322
+ },
1323
+ commerce: false,
1324
+ contacts: {
1325
+ // FORGET A CONTACT, by id or by email. Account deletion — the caller had
1326
+ // to search then remove, which is one round trip it should not have to
1327
+ // know about.
1328
+ remove: async ({ email, fetcher, id, token }) => {
1329
+ const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1330
+ if (!key) return;
1331
+ const contact = id || await lookup({ email, fetcher, token: key });
1332
+ if (!contact) return;
1333
+ return hubspotRequest({
1334
+ fetcher,
1335
+ method: "DELETE",
1336
+ path: "/crm/v3/objects/contacts/" + contact,
1337
+ token: key
1338
+ });
1339
+ },
1340
+ // Connect an account to its contact by email, creating it if absent, and
1341
+ // return the contact id. Unlike SendGrid, HubSpot renames a contact's
1342
+ // email in place, so an email change is a plain PATCH on the cached id —
1343
+ // no delete-old-then-create-new.
1344
+ //
1345
+ // Prefer the cached hubspotId; fall back to a search; create last.
1346
+ sync: async ({ doc, fetcher, token }) => {
1347
+ var _a, _b;
1348
+ const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1349
+ if (!key) return;
1350
+ if (doc == null ? void 0 : doc.hubspotId) {
1351
+ try {
1352
+ return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token: key })) == null ? void 0 : _a.id;
1353
+ } catch (error) {
1354
+ if ((error == null ? void 0 : error.status) !== 404) throw error;
1355
+ }
1356
+ }
1357
+ const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token: key });
1358
+ return (_b = await send({
1359
+ doc,
1360
+ fetcher,
1361
+ method: existing ? "PATCH" : "POST",
1362
+ path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
1363
+ token: key
1364
+ })) == null ? void 0 : _b.id;
1365
+ }
1366
+ },
1367
+ email: false,
1368
+ inbound: false,
1369
+ lifecycle: false,
1370
+ resources: {
1371
+ audiences: false,
1372
+ prices: false,
1373
+ products: false,
1374
+ promotions: false
1375
+ },
1376
+ segment: false,
1377
+ sms: false,
1378
+ webhook: false
1379
+ },
1380
+ icon: drawbridge_default,
1381
+ // Borrowed: the Drawbridge mark, because this is ours and never rendered.
1382
+ private: true,
1383
+ // Absent the token the hooks no-op, so a deployment without a portal simply
1384
+ // contributes nothing rather than failing.
1385
+ requires: ["HUBSPOT_ACCESS_TOKEN"],
1386
+ slug: "hubspot",
1387
+ status: () => "active",
1388
+ // No workflow steps. The hooks are called by the user stream, not the builder.
1389
+ steps: {},
1390
+ tasks: () => [],
1391
+ title: "HubSpot"
1392
+ };
1393
+
259
1394
  // lib/connections/icons/klaviyo.js
260
1395
  var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
261
1396
  <rect width="500" height="500" fill="white"/>
@@ -263,16 +1398,22 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
263
1398
  </svg>`;
264
1399
 
265
1400
  // lib/connections/klaviyo.js
266
- var REVISION = "2026-07-15";
267
- var api = async (path, { fetcher = fetch, token }) => {
1401
+ var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
268
1402
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
1403
+ ...payload && { body: JSON.stringify(payload) },
269
1404
  headers: {
270
1405
  // Bearer, not Klaviyo-API-Key — that header is for private keys, and
271
1406
  // sending it with an OAuth token fails in a way that reads like a bad
272
1407
  // token rather than a bad scheme.
273
1408
  authorization: "Bearer " + token,
274
- revision: REVISION
1409
+ ...payload && { "content-type": "application/json" },
1410
+ // Klaviyo pins its API by DATE. A request without this header is
1411
+ // refused, and one with an old date keeps the response shape that date
1412
+ // shipped with — which is the point: bumping it is a deliberate act
1413
+ // with a changelog to read, not something that drifts under us.
1414
+ revision: "2026-07-15"
275
1415
  },
1416
+ method,
276
1417
  signal: AbortSignal.timeout(15e3)
277
1418
  });
278
1419
  if (!response.ok) {
@@ -281,7 +1422,7 @@ var api = async (path, { fetcher = fetch, token }) => {
281
1422
  { status: response.status }
282
1423
  );
283
1424
  }
284
- return response.json();
1425
+ return response.status === 204 ? null : response.json();
285
1426
  };
286
1427
  var klaviyo_default2 = {
287
1428
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
@@ -289,226 +1430,417 @@ var klaviyo_default2 = {
289
1430
  // carried. Most vendors treat it as optional hardening; this one does not,
290
1431
  // which is why it is a descriptor flag and not a global.
291
1432
  //
292
- // clientAuth is the other thing Klaviyo does differently. The token endpoint
293
- // wants HTTP Basic base64( client_id : client_secret ) in an Authorization
294
- // header — and rejects the same pair sent as form fields, which is how every
295
- // Google product wants it.
1433
+ // HTTP Basic on the token endpoint is the other thing Klaviyo does
1434
+ // differently, and it says so in hooks.auth.token rather than as a flag here.
296
1435
  auth: {
297
1436
  oauth: {
298
- // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
299
- //
300
- // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
301
- // a.klaviyo.com -- their API host -- sends the merchant somewhere that
302
- // never renders a consent screen, so the journey stalls with no error
303
- // anybody can see.
304
- //
305
- // token is a server call and must stay on a.klaviyo.com: Klaviyo began
306
- // blocking OAuth token traffic through www on 2025-03-31, so the mirror
307
- // image of this mistake breaks the exchange instead of the consent.
308
- authorize: "https://www.klaviyo.com/oauth/authorize",
309
1437
  // NAMES the env vars holding OUR application's client. One identity,
310
1438
  // every merchant — the token is the merchant's and arrives from their
311
1439
  // own consent, which is what stops one organization reading another's
312
1440
  // data.
1441
+ //
313
1442
  client: {
314
1443
  id: "KLAVIYO_OAUTH_CLIENT_ID",
315
1444
  secret: "KLAVIYO_OAUTH_CLIENT_SECRET"
316
1445
  },
317
- clientAuth: "basic",
318
- pkce: true,
319
- // DECLARED, never derived from the slug. It is registered in Klaviyo's
320
- // app settings and they refuse anything that does not byte-match, so
321
- // it is a fact about someone else's records rather than a string this
322
- // code computes. Deriving one from a provider key produced
323
- // redirect_uri_mismatch on a connection nobody had touched.
324
1446
  // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
325
1447
  // never mentions this at runtime — you discover it when a refresh fails
326
1448
  // on a connection nobody touched — so it is declared, and it is why
327
1449
  // auth.probe has to run on a schedule rather than only before a call.
328
- idleExpiry: 90 * 24 * 60 * 60,
329
- redirect: "/api/connection/klaviyo/callback",
1450
+ //
1451
+ // Token lifetime is NOT declared: the vendor states it on every
1452
+ // exchange, and a copy here would be a second answer that goes stale.
1453
+ expiry: 90 * 24 * 60 * 60,
1454
+ pkce: true,
330
1455
  // Space separated. accounts:read is required by Klaviyo on every app
331
1456
  // and must stay in the list; the rest are what a contact sync needs.
332
1457
  scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
333
- token: "https://a.klaviyo.com/oauth/token"
1458
+ // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
1459
+ // the disconnect hook — three vendor addresses, two of them declared,
1460
+ // which is exactly the kind of split that goes unnoticed.
1461
+ urls: {
1462
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite ways.
1463
+ //
1464
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it
1465
+ // at a.klaviyo.com — their API host — sends the merchant somewhere
1466
+ // that never renders a consent screen, so the journey stalls with no
1467
+ // error anybody can see.
1468
+ //
1469
+ // token and revoke are server calls and must stay on a.klaviyo.com:
1470
+ // Klaviyo began blocking OAuth token traffic through www on
1471
+ // 2025-03-31, so the mirror image of this mistake breaks the exchange
1472
+ // instead of the consent.
1473
+ authorize: "https://www.klaviyo.com/oauth/authorize",
1474
+ // WHERE THE MERCHANT LANDS — the dashboard, not drawbridge-api. The
1475
+ // `/api/` segment is Next's route-handler convention, which reads as
1476
+ // the api service to everyone who sees it; it is not, and the route
1477
+ // has never moved. build() pins it against the one callback route
1478
+ // that exists, because declared-but-wrong fails AFTER consent — a
1479
+ // 404 for someone who has already granted access.
1480
+ //
1481
+ // Registered in Klaviyo's own app settings, and they refuse anything
1482
+ // that does not byte-match, so it is a fact about someone else's
1483
+ // records rather than a string this code computes.
1484
+ redirect: "/api/connection/klaviyo/callback",
1485
+ revoke: "https://a.klaviyo.com/oauth/revoke",
1486
+ token: "https://a.klaviyo.com/oauth/token"
1487
+ }
334
1488
  },
335
1489
  type: "oauth"
336
1490
  },
337
- category: "contacts",
338
- confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
339
- connect: {
1491
+ // EVERYTHING A MERCHANT READS. Grouped by who it is for rather than by what
1492
+ // kind of sentence it is, so the question on the next vendor is "does a person
1493
+ // read this", which nobody gets wrong, instead of "is this marketing", which
1494
+ // someone will.
1495
+ //
1496
+ // `errors` is in here rather than at the top level, and that is not a
1497
+ // preference. The connection DOCUMENT carries its own `errors` array of
1498
+ // scope-drift entries, and the document is spread OVER the resolved manifest
1499
+ // downstream — so a top-level `errors` here would be silently replaced by that
1500
+ // array and this copy would never render. `fields`/`settings` already carry a
1501
+ // comment about the same collision.
1502
+ content: {
1503
+ // Shown at disconnect, so it says what is lost and what is not.
1504
+ confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
1505
+ description: [
1506
+ "Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.",
1507
+ "You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.",
1508
+ "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."
1509
+ ],
1510
+ // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
1511
+ // likely to grow — resources.* has already earned somewhere to put "we
1512
+ // could not load your lists" — so a new area adds a key here rather than a
1513
+ // second errors object somewhere else.
1514
+ //
1515
+ // `connect` no longer exists as a container above: its only other member
1516
+ // was `redirect`, which moved to auth.oauth.urls with the rest of the
1517
+ // vendor's addresses.
340
1518
  errors: {
341
- denied: "The Klaviyo authorization was declined, so nothing was connected.",
342
- invalid: "We couldn't complete the Klaviyo connection. Try connecting again."
343
- }
1519
+ connect: {
1520
+ denied: "The Klaviyo authorization was declined, so nothing was connected.",
1521
+ invalid: "We couldn't complete the Klaviyo connection. Try connecting again."
1522
+ }
1523
+ },
1524
+ excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
1525
+ // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
1526
+ // rendered — four useful instructions no component displayed.
1527
+ guide: [
1528
+ "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
1529
+ "Sign in to Klaviyo if you are not already, and choose the account to connect.",
1530
+ "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
1531
+ "You can revoke access at any time from Klaviyo, under Integrations."
1532
+ ]
344
1533
  },
345
- description: [
346
- "Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.",
347
- "You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.",
348
- "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."
349
- ],
350
- excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
1534
+ // CAN A MERCHANT KEEP TWO OF THESE AT ONCE?
1535
+ //
1536
+ // Required, and false is a decision rather than a default. Shopify is
1537
+ // exclusive because a store maps to exactly one organization. Contact syncs
1538
+ // are destinations — someone can reasonably keep Klaviyo and Mailchimp both
1539
+ // current — so the exclusivity that once applied when these were SENDERS is
1540
+ // deliberately gone. That was removed once already; declaring it out loud is
1541
+ // what stops it coming back by inference.
1542
+ exclusive: false,
351
1543
  feature: "organization:connection:klaviyo",
352
- // Nothing typed. The consent returns the grant, and the account it belongs to
353
- // is read back from Klaviyo rather than asked for.
354
1544
  fields: [
355
1545
  {
356
1546
  key: "account",
357
1547
  label: "Klaviyo account"
358
1548
  },
359
1549
  {
1550
+ // The choices come from the merchant's own account, so the field names
1551
+ // the capability and the client composes the url.
1552
+ hook: "resources.audiences",
360
1553
  input: "select",
361
1554
  key: "list",
362
1555
  label: "Klaviyo list",
363
1556
  message: "Contacts your campaigns collect are synced into this list.",
364
1557
  required: true,
365
- // The choices come from the merchant's own account, not from here see
366
- // catalog.audiences below. Static options would mean asking somebody to
367
- // paste a list id copied out of another browser tab.
368
- source: "catalog.audiences"
1558
+ // Klaviyo's list endpoint carries no name filter, so the hook can only
1559
+ // match what it already fetched. A search box that searches one page is
1560
+ // worse than none, so the picker does not offer one.
1561
+ search: false
369
1562
  }
370
1563
  ],
371
- // The three auth hooks, all pure HTTP against Klaviyowhich is why they
372
- // live here rather than in sync. A vendor's own protocol belongs beside the
373
- // vendor.
1564
+ // WHAT KIND OF THING THIS IS. One field, not two `category` said the same
1565
+ // thing and was read by nothing, while `group` was quietly doing double duty
1566
+ // as the mutual-exclusion key. The exclusion moved to `exclusive` above, so
1567
+ // this is purely how a connection is grouped and labelled.
1568
+ group: "contacts",
1569
+ // Nothing typed at connect. The consent returns the grant, and the account it
1570
+ // belongs to is read back from Klaviyo rather than asked for.
374
1571
  hooks: {
375
- // Turn a fresh grant into settings worth showing. Without this the card
376
- // renders an empty "Klaviyo account" field, because the merchant is never
377
- // asked which account they connected the consent already decided it and
378
- // asking again would be a question we can answer ourselves.
379
- "auth.connect": async ({ fetcher, tokens }) => {
380
- var _a, _b, _c;
381
- const body = await api("/accounts", { fetcher, token: tokens.accessToken });
382
- const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
383
- return {
384
- 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,
385
- accountId: (account == null ? void 0 : account.id) || null
386
- };
1572
+ auth: {
1573
+ // Turn a fresh grant into settings worth showing. Without this the card
1574
+ // renders an empty "Klaviyo account" field, because the merchant is
1575
+ // never asked which account they connected the consent already
1576
+ // decided it, and asking again would be a question we can answer.
1577
+ connect: async ({ fetcher, tokens }) => {
1578
+ var _a, _b, _c;
1579
+ const body = await api("/accounts", { fetcher, token: tokens.accessToken });
1580
+ const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
1581
+ return {
1582
+ 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,
1583
+ accountId: (account == null ? void 0 : account.id) || null
1584
+ };
1585
+ },
1586
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
1587
+ // grant live in the merchant's account, so a disconnect that looks
1588
+ // complete here still shows Drawbridge with access over there.
1589
+ //
1590
+ // Basic auth with our client, exactly like the token exchange — the
1591
+ // token being revoked is the subject, not the credential.
1592
+ disconnect: async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
1593
+ const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
1594
+ if (!token) return { revoked: false };
1595
+ const response = await fetcher(manifest.auth.oauth.urls.revoke, {
1596
+ body: new URLSearchParams({
1597
+ token,
1598
+ token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
1599
+ }),
1600
+ headers: {
1601
+ authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
1602
+ "content-type": "application/x-www-form-urlencoded"
1603
+ },
1604
+ method: "POST",
1605
+ signal: AbortSignal.timeout(15e3)
1606
+ });
1607
+ return { revoked: response.ok };
1608
+ },
1609
+ // THE MINT IS THE PROBE. Asking "is this token still good" by
1610
+ // inspecting what we stored answers the wrong question — a grant
1611
+ // revoked inside Klaviyo still looks perfect in our database. Spending
1612
+ // the refresh token is the only thing that asks Klaviyo.
1613
+ //
1614
+ // It also keeps the grant warm against the 90-day idle window above.
1615
+ probe: async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
1616
+ const token = await accessToken({
1617
+ clientId,
1618
+ clientSecret,
1619
+ fetcher,
1620
+ // Mint even if the stored token still looks good — a probe that
1621
+ // short-circuits never reaches Klaviyo and reports healthy on a
1622
+ // grant revoked an hour ago.
1623
+ force: true,
1624
+ manifest,
1625
+ settings
1626
+ });
1627
+ return { ok: Boolean(token) };
1628
+ },
1629
+ // Klaviyo scopes are fixed at app level and re-consented, not drifted.
1630
+ scopes: false,
1631
+ // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
1632
+ // client_id/client_secret pair as body fields. Everything else about the
1633
+ // request is standard, so this is the shared implementation told the one
1634
+ // thing that differs — in Klaviyo's own file, beside the rest of what
1635
+ // makes Klaviyo unusual, rather than as a flag a caller has to know to
1636
+ // read.
1637
+ token: (args) => authToken({ ...args, basic: true })
387
1638
  },
388
- // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
389
- // grant live in the merchant's account, so a disconnect that looks
390
- // complete here still shows Drawbridge with access over there.
391
- //
392
- // Basic auth with our client, exactly like the token exchange — the token
393
- // being revoked is the subject, not the credential.
394
- // The lists a merchant can sync into, for the picker on their connection.
395
- //
396
- // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
397
- // so a single call quietly returns the first ten lists and an account with
398
- // more would show a picker missing the one they wanted — with nothing to
399
- // indicate anything was cut. Follows links.next, bounded so a runaway
400
- // cursor cannot spin forever.
401
- "catalog.audiences": async ({ fetcher, token }) => {
402
- var _a, _b;
403
- const audiences = [];
404
- let path = "/lists?page%5Bsize%5D=10";
405
- let pages = 0;
406
- while (path && pages < 20) {
407
- const body = await api(path, { fetcher, token });
408
- for (const list of (body == null ? void 0 : body.data) || []) {
409
- audiences.push({ label: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id, value: list.id });
410
- }
411
- const next = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
412
- path = next ? String(next).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
413
- pages = pages + 1;
1639
+ // No commerce here. Klaviyo tracks orders, but Drawbridge's order data comes
1640
+ // from the store that took the money a second source for the same event
1641
+ // is two answers to "did this person buy", and the one we can bill from is
1642
+ // the store's.
1643
+ commerce: false,
1644
+ // The verb the contacts.sync step points at. It does the work — including
1645
+ // writing the profile id back onto the lead and returns what happened.
1646
+ contacts: {
1647
+ // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1648
+ // different thing from deleting the profile.
1649
+ remove: false,
1650
+ sync: async ({ contact, fetcher, lead, settings, suppressed, token }) => {
1651
+ var _a, _b, _c;
1652
+ const list = settings == null ? void 0 : settings.list;
1653
+ if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
1654
+ 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);
1655
+ if (!email) return { message: "That lead has no email address to sync.", skipped: true };
1656
+ const totals = (contact == null ? void 0 : contact.totals) || {};
1657
+ const profile = await api("/profiles/", {
1658
+ fetcher,
1659
+ method: "POST",
1660
+ payload: {
1661
+ data: {
1662
+ attributes: {
1663
+ email,
1664
+ ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
1665
+ properties: {
1666
+ drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
1667
+ drawbridge_draws: totals.draws || 0,
1668
+ drawbridge_entries: totals.entries || 0,
1669
+ drawbridge_orders: totals.orders || 0,
1670
+ // Campaign-attributed, NOT lifetime. A merchant running
1671
+ // Shopify already has lifetime revenue in Klaviyo through
1672
+ // Klaviyo's own integration; what only we can say is how
1673
+ // much a campaign drove. Named so the two cannot be
1674
+ // mistaken for one another in a segment builder.
1675
+ drawbridge_revenue: totals.gross || 0
1676
+ }
1677
+ },
1678
+ type: "profile"
1679
+ }
1680
+ },
1681
+ token
1682
+ });
1683
+ const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
1684
+ if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
1685
+ await api("/profile-subscription-bulk-create-jobs/", {
1686
+ fetcher,
1687
+ method: "POST",
1688
+ payload: {
1689
+ data: {
1690
+ attributes: {
1691
+ profiles: {
1692
+ data: [{
1693
+ attributes: {
1694
+ email,
1695
+ subscriptions: {
1696
+ email: { marketing: { consent: suppressed ? "UNSUBSCRIBED" : "SUBSCRIBED" } }
1697
+ }
1698
+ },
1699
+ type: "profile"
1700
+ }]
1701
+ }
1702
+ },
1703
+ relationships: { list: { data: { id: list, type: "list" } } },
1704
+ type: "profile-subscription-bulk-create-job"
1705
+ }
1706
+ },
1707
+ token
1708
+ });
1709
+ return {
1710
+ // Merged into `context` for later steps in this run.
1711
+ context: { klaviyoProfileId: profileId },
1712
+ message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
1713
+ // Recorded on the run for support to read back, not a write
1714
+ // instruction — the hook has already written what it needed to.
1715
+ response: { klaviyoProfileId: profileId }
1716
+ };
414
1717
  }
415
- return audiences;
416
1718
  },
417
- "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
418
- const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
419
- if (!token) return { revoked: false };
420
- const response = await fetcher("https://a.klaviyo.com/oauth/revoke", {
421
- body: new URLSearchParams({
422
- token,
423
- token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
424
- }),
425
- headers: {
426
- authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
427
- "content-type": "application/x-www-form-urlencoded"
428
- },
429
- method: "POST",
430
- signal: AbortSignal.timeout(15e3)
431
- });
432
- return { revoked: response.ok };
1719
+ // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
1720
+ // register nothing with it, so listing four falses would be noise around a
1721
+ // single decision. Still explicit absence would not say whether anybody
1722
+ // considered it.
1723
+ // Drawbridge sends its own notification email and SMS, and owns its own
1724
+ // segments — see the private `drawbridge` manifest. A vendor answering
1725
+ // these would be a second sender, which is the arrangement the platform
1726
+ // sender replaced.
1727
+ email: false,
1728
+ segment: false,
1729
+ sms: false,
1730
+ inbound: false,
1731
+ // Nothing to set up or tear down at the vendor: the grant is the whole
1732
+ // integration, and revoking it is auth.disconnect's job.
1733
+ lifecycle: false,
1734
+ resources: {
1735
+ // The lists a merchant can sync into, for the picker on their
1736
+ // connection.
1737
+ //
1738
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to
1739
+ // it, so one call quietly returns the first ten lists and an account
1740
+ // with more shows a picker missing the one they wanted, with nothing to
1741
+ // indicate anything was cut.
1742
+ audiences: async ({ cursor, fetcher, limit = 100, search, token }) => {
1743
+ var _a, _b;
1744
+ const audiences = [];
1745
+ let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
1746
+ let pages = 0;
1747
+ while (next && audiences.length < limit && pages < 20) {
1748
+ const body = await api(next, { fetcher, token });
1749
+ for (const list of (body == null ? void 0 : body.data) || []) {
1750
+ audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
1751
+ }
1752
+ const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
1753
+ next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
1754
+ pages = pages + 1;
1755
+ }
1756
+ const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
1757
+ return {
1758
+ items: term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences,
1759
+ pageInfo: {
1760
+ endCursor: next,
1761
+ hasNextPage: Boolean(next)
1762
+ }
1763
+ };
1764
+ },
1765
+ // Klaviyo sells no products and mints no discount codes.
1766
+ prices: false,
1767
+ products: false,
1768
+ promotions: false
433
1769
  },
434
- // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
435
- // what we stored answers the wrong question — a grant revoked inside
436
- // Klaviyo still looks perfect in our database. Spending the refresh token
437
- // is the only thing that asks Klaviyo.
438
- //
439
- // It also keeps the grant warm: Klaviyo expires a refresh token after 90
440
- // days of NON-USE, so a connection nobody touches dies silently without
441
- // this running.
442
- "auth.probe": async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
443
- const token = await accessToken({
444
- clientId,
445
- clientSecret,
446
- fetcher,
447
- // Mint even if the stored token still looks good — a probe that
448
- // short-circuits never reaches Klaviyo and reports healthy on a
449
- // grant revoked an hour ago.
450
- force: true,
451
- manifest,
452
- settings
453
- });
454
- return { ok: Boolean(token) };
455
- }
1770
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
1771
+ webhook: false
456
1772
  },
457
1773
  icon: klaviyo_default,
458
- // A grant with no list chosen is authenticated and useless. The list cannot
459
- // be part of the consent flow — enumerating lists needs the token the consent
460
- // returns — so it is always a second step, and the card must say so rather
461
- // than showing Active over nothing.
462
- incomplete: (data) => {
463
- var _a;
464
- return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.list) ? null : "Choose which Klaviyo list your contacts should sync into.";
465
- },
466
- label: "klaviyo",
467
1774
  requires: [
468
1775
  "KLAVIYO_OAUTH_CLIENT_ID",
469
1776
  "KLAVIYO_OAUTH_CLIENT_SECRET"
470
1777
  ],
471
- setup: [
472
- "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
473
- "Sign in to Klaviyo if you are not already, and choose the account to connect.",
474
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
475
- "You can revoke access at any time from Klaviyo, under Integrations."
476
- ],
477
1778
  slug: "klaviyo",
478
- // No steps yet. The sync itself is unbuilt, and a step offered in the builder
479
- // that nothing runs is worse than no step at all the merchant configures it
480
- // and waits for something that never happens.
481
- steps: {},
482
- supports: {
483
- "auth.connect": true,
484
- "auth.disconnect": true,
485
- // The refresh mint IS the probe: a revoked or rotated grant fails there in
486
- // Klaviyo's own words rather than as an empty sync three steps later.
487
- "auth.probe": true,
488
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
489
- "auth.scopes": false,
490
- "catalog.audiences": true,
491
- "catalog.prices": false,
492
- "catalog.products": false,
493
- "catalog.promotions": false,
494
- "inbound.event": false,
495
- "inbound.process": false,
496
- "inbound.receive": false,
497
- "inbound.verify": false,
498
- "lifecycle.cleanup": false,
499
- "lifecycle.register": false,
500
- "lifecycle.rehydrate": false
501
- },
502
- tasks: () => [
503
- // Mailchimp carries the same warning, deliberately worded the same way. A
504
- // merchant who connects either one and is told nothing reasonably assumes
505
- // contacts are flowing, and finds out weeks later that they are not.
506
- {
507
- message: "Contact syncing to Klaviyo lists has not shipped yet. Connecting stores your authorization so it is ready, but nothing is being sent to Klaviyo right now.",
508
- title: "List sync not available yet",
509
- type: "warning"
1779
+ // ONE OF THE FOUR STATES AND NOTHING ELSE the reason sits in `tasks`, which
1780
+ // is already the merchant-facing copy channel and is already rendered.
1781
+ //
1782
+ // A grant with no list chosen is authenticated and useless. The list cannot be
1783
+ // part of the consent flow — enumerating lists needs the token the consent
1784
+ // returns — so it is always a second step, and the card must say Pending
1785
+ // rather than Active over nothing.
1786
+ //
1787
+ // Otherwise the credential's own verdict stands. A manifest can only ever
1788
+ // DOWNGRADE: it can see the settings, and it cannot see whether the grant was
1789
+ // revoked at Klaviyo an hour ago.
1790
+ //
1791
+ // Computed at read time rather than written, for the same reason
1792
+ // shopifyMissingScopes is: it becomes true the moment a merchant clears the
1793
+ // list, without waiting for something to notice and write it down.
1794
+ status: (data2) => {
1795
+ var _a;
1796
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
1797
+ },
1798
+ steps: {
1799
+ contacts: {
1800
+ // A DECLARATION, not the work. It names the hook that does the work, and
1801
+ // says where that hook's values belong. Nested like the hooks, and the
1802
+ // nesting IS the name: this is `step.contacts.sync`, which is what a
1803
+ // workflow document stores.
1804
+ //
1805
+ // A function, so it can depend on what this deployment or this
1806
+ // merchant's connection knows a static object would have to be true
1807
+ // for every deployment at publish time.
1808
+ sync: ({ data: data2 }) => {
1809
+ var _a;
1810
+ return {
1811
+ hook: "contacts.sync",
1812
+ // The account the merchant actually connected, read back by
1813
+ // auth.connect. The builder reads "Sync contact to Acme Co" rather
1814
+ // than a label that could be any of their Klaviyo accounts.
1815
+ key: "Sync contact to " + (((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.account) || "Klaviyo"),
1816
+ queue: "connection",
1817
+ // Nothing for a merchant to configure on the step itself — the list
1818
+ // is chosen once on the connection. Declared empty rather than
1819
+ // omitted, so "this step takes no settings" and "nobody thought about
1820
+ // settings" are different statements.
1821
+ settings: {},
1822
+ // BOTH triggers. lead.insert alone only ever fires for someone with
1823
+ // no history yet — a brand-new entrant has no orders and no revenue,
1824
+ // so a profile written then carries an email and nothing else.
1825
+ // Crossing into a segment is the moment the ranking data exists.
1826
+ triggers: ["lead.insert", "segment.contact.add"],
1827
+ // One source for cost: what the builder discloses before a merchant
1828
+ // adds this step, and what is charged when it runs.
1829
+ usage: { actions: 1 }
1830
+ };
1831
+ }
510
1832
  }
511
- ],
1833
+ },
1834
+ // WHY, in the merchant's words, and what to do about it.
1835
+ tasks: (data2) => {
1836
+ var _a;
1837
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
1838
+ {
1839
+ message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
1840
+ title: "Choose a list"
1841
+ }
1842
+ ];
1843
+ },
512
1844
  title: "Klaviyo"
513
1845
  };
514
1846
 
@@ -545,13 +1877,28 @@ var mailchimp_default2 = {
545
1877
  auth: {
546
1878
  type: "keys"
547
1879
  },
548
- category: "contacts",
549
- confirm: "Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
550
- description: [
551
- "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.",
552
- "This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so a key stored here does nothing today."
553
- ],
554
- excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
1880
+ // EVERYTHING A MERCHANT READS. `errors` belongs in here rather than at the
1881
+ // top level because the connection DOCUMENT carries its own `errors` array
1882
+ // and the document is spread OVER the resolved manifest downstream — a
1883
+ // top-level one would be replaced by that array and never render.
1884
+ content: {
1885
+ confirm: "Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
1886
+ description: [
1887
+ "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.",
1888
+ "This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so a key stored here does nothing today."
1889
+ ],
1890
+ excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
1891
+ guide: [
1892
+ "In Mailchimp, open Account & billing, then Extras, then API keys.",
1893
+ "Create a key and copy it.",
1894
+ "Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on."
1895
+ ]
1896
+ },
1897
+ // Mailchimp and SendGrid shared a group while they were SENDERS, where an org
1898
+ // picking two providers to send the same mail was meaningless. As contact
1899
+ // syncs they are destinations, and a merchant could reasonably keep several
1900
+ // up to date, so the exclusivity is gone.
1901
+ exclusive: false,
555
1902
  feature: "organization:connection:mailchimp",
556
1903
  fields: [
557
1904
  {
@@ -568,30 +1915,61 @@ var mailchimp_default2 = {
568
1915
  key: "audience",
569
1916
  label: "Mailchimp audience",
570
1917
  message: "Contacts your campaigns collect are synced into this audience.",
1918
+ hook: "resources.audiences",
571
1919
  required: true,
572
- source: "catalog.audiences"
1920
+ // Mailchimp's /lists takes no name filter either — same reason.
1921
+ search: false
573
1922
  }
574
1923
  ],
575
- // The audiences a merchant can sync into, for the picker on their connection.
576
- //
577
- // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
578
- // it unset returns the first ten audiences and looks entirely successful
579
- // the same silent truncation Klaviyo has, at a different number. Paged
580
- // against total_items so an account past a thousand still resolves.
1924
+ group: "contacts",
1925
+ // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
1926
+ // else is built yet, because audience sync has not shipped. Every false here
1927
+ // is "not yet" rather than "never" when the sync lands, probe and
1928
+ // contacts.sync are the first to flip.
581
1929
  hooks: {
582
- "catalog.audiences": async ({ fetcher = fetch, settings }) => {
583
- const key = settings == null ? void 0 : settings.apiKey;
584
- const headers = {
585
- // Basic with any username — Mailchimp reads only the password half.
586
- authorization: "Basic " + Buffer.from("drawbridge:" + key).toString("base64")
587
- };
588
- const audiences = [];
589
- let offset = 0;
590
- let total = null;
591
- while (total === null || offset < total && offset < 5e3) {
1930
+ auth: {
1931
+ // Implemented outside this package: storing a typed key needs no vendor
1932
+ // call, so the api's own form handler does it.
1933
+ connect: {},
1934
+ disconnect: {},
1935
+ probe: false,
1936
+ scopes: false,
1937
+ // Keys today. When Mailchimp's OAuth lands this becomes a wrapper that
1938
+ // follows the exchange with /oauth2/metadata — the data-centre call that
1939
+ // is the whole reason its OAuth cannot be pure configuration.
1940
+ token: false
1941
+ },
1942
+ commerce: false,
1943
+ contacts: { remove: false, sync: false },
1944
+ // Drawbridge sends its own notification email and SMS, and owns its own
1945
+ // segments — see the private `drawbridge` manifest. A vendor answering
1946
+ // these would be a second sender, which is the arrangement the platform
1947
+ // sender replaced.
1948
+ email: false,
1949
+ segment: false,
1950
+ sms: false,
1951
+ inbound: false,
1952
+ lifecycle: false,
1953
+ resources: {
1954
+ // The audiences a merchant can sync into, for the picker on their
1955
+ // connection.
1956
+ //
1957
+ // count DEFAULTS TO 10 and maxes at 1000 (Mailchimp's own OpenAPI spec),
1958
+ // so leaving it unset returns the first ten audiences and looks entirely
1959
+ // successful — the same silent truncation Klaviyo has, at a different
1960
+ // number. Paged against total_items so an account past a thousand still
1961
+ // resolves.
1962
+ audiences: async ({ cursor, fetcher = fetch, limit = 100, search, settings }) => {
1963
+ const key = settings == null ? void 0 : settings.apiKey;
1964
+ const count = Math.min(limit, 1e3);
1965
+ const offset = Number(cursor || 0);
592
1966
  const response = await fetcher(
593
- base(key) + "/lists?count=1000&offset=" + offset + "&fields=lists.id,lists.name,total_items",
594
- { headers, signal: AbortSignal.timeout(15e3) }
1967
+ base(key) + "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
1968
+ {
1969
+ // Basic with any username — Mailchimp reads only the password half.
1970
+ headers: { authorization: "Basic " + Buffer.from("drawbridge:" + key).toString("base64") },
1971
+ signal: AbortSignal.timeout(15e3)
1972
+ }
595
1973
  );
596
1974
  if (!response.ok) {
597
1975
  throw Object.assign(
@@ -600,64 +1978,57 @@ var mailchimp_default2 = {
600
1978
  );
601
1979
  }
602
1980
  const body = await response.json();
603
- for (const list of (body == null ? void 0 : body.lists) || []) {
604
- audiences.push({ label: (list == null ? void 0 : list.name) || list.id, value: list.id });
605
- }
606
- total = (body == null ? void 0 : body.total_items) ?? audiences.length;
607
- offset = offset + 1e3;
608
- }
609
- return audiences;
610
- }
1981
+ const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
1982
+ const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
1983
+ const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
1984
+ const nextOffset = offset + count;
1985
+ const more = nextOffset < Number((body == null ? void 0 : body.total_items) || 0);
1986
+ return {
1987
+ items,
1988
+ pageInfo: {
1989
+ endCursor: more ? String(nextOffset) : null,
1990
+ hasNextPage: more
1991
+ }
1992
+ };
1993
+ },
1994
+ prices: false,
1995
+ products: false,
1996
+ promotions: false
1997
+ },
1998
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
1999
+ webhook: false
611
2000
  },
612
2001
  icon: mailchimp_default,
2002
+ slug: "mailchimp",
613
2003
  // A key with no audience chosen is authenticated and inert. Mailchimp also
614
2004
  // needs its merge fields created on that audience before any Drawbridge total
615
2005
  // can be written to a member — unlike Klaviyo, its custom fields are not
616
2006
  // schemaless — so the audience must be picked before lifecycle.register has
617
2007
  // anything to register against.
618
- incomplete: (data) => {
2008
+ status: (data2) => {
619
2009
  var _a;
620
- return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.audience) ? null : "Choose which Mailchimp audience your contacts should sync into.";
2010
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? data2.status : "pending";
621
2011
  },
622
- label: "mailchimp",
623
- // Uniform surface, honest answers. A key is stored and can be removed; nothing
624
- // else is built yet, because audience sync has not shipped. Every false here
625
- // is "not yet", not "never" — when the sync lands, probe and catalog become
626
- // the first two to flip.
627
- supports: {
628
- "auth.connect": true,
629
- "auth.disconnect": true,
630
- "auth.probe": false,
631
- "auth.scopes": false,
632
- "catalog.audiences": true,
633
- "catalog.prices": false,
634
- "catalog.products": false,
635
- "catalog.promotions": false,
636
- "inbound.event": false,
637
- "inbound.process": false,
638
- "inbound.receive": false,
639
- "inbound.verify": false,
640
- "lifecycle.cleanup": false,
641
- "lifecycle.register": false,
642
- "lifecycle.rehydrate": false
643
- },
644
- setup: [
645
- "In Mailchimp, open Account & billing, then Extras, then API keys.",
646
- "Create a key and copy it.",
647
- "Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on."
648
- ],
649
- slug: "mailchimp",
650
2012
  // No steps: audience sync has not shipped, so this vendor contributes nothing
651
2013
  // to a workflow yet. An empty steps object is the honest declaration — the
652
2014
  // catalog renders the connection, and no builder offers a step it cannot run.
653
2015
  steps: {},
654
- tasks: () => [
655
- {
656
- 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.",
657
- title: "Audience sync not available yet",
658
- type: "warning"
659
- }
660
- ],
2016
+ tasks: (data2) => {
2017
+ var _a;
2018
+ return [
2019
+ ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
2020
+ {
2021
+ message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
2022
+ title: "Choose an audience"
2023
+ }
2024
+ ],
2025
+ {
2026
+ 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.",
2027
+ title: "Audience sync not available yet",
2028
+ type: "warning"
2029
+ }
2030
+ ];
2031
+ },
661
2032
  title: "Mailchimp"
662
2033
  };
663
2034
 
@@ -713,31 +2084,48 @@ var shopify_default2 = {
713
2084
  auth: {
714
2085
  type: "install"
715
2086
  },
716
- category: "commerce",
717
- confirm: "Disconnecting deactivates all products from this store, drafts any advertisements that use them, disables Shopify steps in your workflows until you reconnect, and stops revenue tracking for this organization.",
718
- // How connecting is DESCRIBED the copy and destination. What kind of connect
719
- // it is lives in auth.type, once, so the two cannot disagree.
2087
+ // EVERYTHING A MERCHANT READS.
2088
+ //
2089
+ // `errors` is in here rather than at the top level, and that is not a
2090
+ // preference: the connection DOCUMENT carries its own `errors` array of
2091
+ // scope-drift entries, and the document is spread OVER the resolved manifest
2092
+ // downstream — a top-level one would be replaced by that array and never
2093
+ // render.
720
2094
  //
721
- // The redirect title is copy: it names where the link GOES rather than what it
722
- // does, since installing happens on the App Store listing and the dashboard
723
- // must never imply a store can be linked from inside it.
724
- connect: {
2095
+ // `connect` no longer exists as a container. Its other member was `redirect`,
2096
+ // which is a URL and now sits with the vendor's other addresses.
2097
+ content: {
2098
+ confirm: "Disconnecting deactivates all products from this store, drafts any advertisements that use them, disables Shopify steps in your workflows until you reconnect, and stops revenue tracking for this organization.",
2099
+ description: [
2100
+ "Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.",
2101
+ "Drawbridge attributes orders that originate from your campaigns \u2014 matched through cart parameters and lead-mapped discount codes \u2014 so you can see the revenue each campaign drives.",
2102
+ "On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the resources."
2103
+ ],
725
2104
  errors: {
726
- conflict: "This store is already connected to another Drawbridge organization.",
727
- currency: "This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.",
728
- invalid: "We couldn't verify the install. Please try connecting again from the Shopify App Store."
2105
+ connect: {
2106
+ conflict: "This store is already connected to another Drawbridge organization.",
2107
+ currency: "This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.",
2108
+ invalid: "We couldn't verify the install. Please try connecting again from the Shopify App Store."
2109
+ }
729
2110
  },
2111
+ excerpt: "Connect your Shopify store to feature products in your campaigns and track conversions.",
2112
+ guide: [
2113
+ "Open the Drawbridge listing on the Shopify App Store.",
2114
+ "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
2115
+ "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
2116
+ "Come back here \u2014 the connections list updates on its own once the install lands."
2117
+ ],
2118
+ // Names where the link GOES rather than what it does: installing happens on
2119
+ // the App Store listing, and the dashboard must never imply a store can be
2120
+ // linked from inside it.
730
2121
  redirect: {
731
2122
  env: "SHOPIFY_APP_LISTING_URL",
732
2123
  title: "View on the Shopify App Store"
733
2124
  }
734
2125
  },
735
- description: [
736
- "Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.",
737
- "Drawbridge attributes orders that originate from your campaigns \u2014 matched through cart parameters and lead-mapped discount codes \u2014 so you can see the revenue each campaign drives.",
738
- "On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the catalog."
739
- ],
740
- excerpt: "Connect your Shopify store to feature products in your campaigns and track conversions.",
2126
+ // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
2127
+ // attributed order two possible sources.
2128
+ exclusive: true,
741
2129
  feature: "organization:connection:shopify",
742
2130
  fields: [
743
2131
  {
@@ -748,12 +2136,9 @@ var shopify_default2 = {
748
2136
  label: "Store domain"
749
2137
  }
750
2138
  ],
751
- group: "ecommerce",
752
- // The install is the whole configuration Shopify hands back the shop and
753
- // there is nothing further to choose. `shop` absent means the install did not
754
- // finish, which is a credential problem rather than a setup one, so the
755
- // stored status already says so.
756
- incomplete: () => null,
2139
+ // Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
2140
+ // fact, which left nobody able to say which one a page read.
2141
+ group: "commerce",
757
2142
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
758
2143
  // is exactly the shape it covers, so there is nothing vendor-specific to
759
2144
  // write for either. receive is the one hook that genuinely differs by
@@ -761,25 +2146,91 @@ var shopify_default2 = {
761
2146
  // /compliance enforces the topic allowlist above, because answering one late
762
2147
  // is a legal deadline rather than a retry.
763
2148
  hooks: {
764
- "inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
765
- "inbound.receive": ({ channel, event, headers, payload }) => {
766
- if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
767
- throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
768
- }
769
- return {
770
- // Compliance payloads already carry shop_domain in the body — Shopify's
771
- // own GDPR shape. The app-level event stream does not; that domain
772
- // lives only in the header, so it is stamped on here rather than left
773
- // for drawbridge-sync to reach into headers nobody hands it.
774
- data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
775
- provider: { id: headers[inbound.headers.id] || null }
776
- };
2149
+ auth: {
2150
+ // The install completes inside Shopify admin; the api's callback stores
2151
+ // what it hands back. auth.probe is false deliberately: the health check
2152
+ // re-registers rather than answering "is this token still good", and
2153
+ // scope drift is its own hook because a token can be perfectly valid
2154
+ // while the grant is too narrow.
2155
+ connect: {},
2156
+ disconnect: {},
2157
+ probe: false,
2158
+ scopes: {},
2159
+ // Shopify's install grant is exchanged inside its own app flow, not
2160
+ // through the shared OAuth runner.
2161
+ token: false
777
2162
  },
778
- "inbound.verify": (args) => verifySignature({ ...args, descriptor: inbound })
2163
+ // Implemented in drawbridge-sync, which owns the attribution and the
2164
+ // controllers it needs. Declared here so the steps below can point at them:
2165
+ // a step naming a hook the vendor does not implement is a workflow that
2166
+ // accepts the step and then silently does nothing.
2167
+ commerce: {
2168
+ code: {},
2169
+ customer: {},
2170
+ order: {},
2171
+ product: {}
2172
+ },
2173
+ contacts: { remove: false, sync: false },
2174
+ // verify and event lean entirely on the shared HMAC helper — Shopify's
2175
+ // scheme is exactly the shape it covers, so there is nothing vendor-specific
2176
+ // to write for either. receive is the one hook that genuinely differs by
2177
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
2178
+ // /compliance enforces the topic allowlist above, because answering one late
2179
+ // is a legal deadline rather than a retry.
2180
+ // Drawbridge sends its own notification email and SMS, and owns its own
2181
+ // segments — see the private `drawbridge` manifest. A vendor answering
2182
+ // these would be a second sender, which is the arrangement the platform
2183
+ // sender replaced.
2184
+ email: false,
2185
+ segment: false,
2186
+ sms: false,
2187
+ inbound: {
2188
+ event: (args) => readEventHeader({ ...args, descriptor: inbound }),
2189
+ process: {},
2190
+ receive: ({ channel, event, headers, payload }) => {
2191
+ if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
2192
+ throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
2193
+ }
2194
+ return {
2195
+ // Compliance payloads already carry shop_domain in the body — Shopify's
2196
+ // own GDPR shape. The app-level event stream does not; that domain
2197
+ // lives only in the header, so it is stamped on here rather than left
2198
+ // for drawbridge-sync to reach into headers nobody hands it.
2199
+ data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
2200
+ provider: { id: headers[inbound.headers.id] || null }
2201
+ };
2202
+ },
2203
+ verify: (args) => verifySignature({ ...args, descriptor: inbound })
2204
+ },
2205
+ lifecycle: { cleanup: {}, health: {}, register: {}, rehydrate: {} },
2206
+ resources: {
2207
+ audiences: false,
2208
+ // Shopify has no separate price resource — a price belongs to a product
2209
+ // variant and arrives with it, so there is nothing for prices to answer
2210
+ // that products does not already.
2211
+ prices: false,
2212
+ products: {},
2213
+ promotions: {}
2214
+ },
2215
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
2216
+ webhook: false
779
2217
  },
780
2218
  icon: shopify_default,
781
2219
  inbound,
782
- label: "shopify",
2220
+ // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
2221
+ //
2222
+ // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
2223
+ // in the shared resolver — a hardcoded vendor branch in code every vendor runs
2224
+ // through, which is the arrangement these manifests exist to remove.
2225
+ //
2226
+ // Undefined until a shop is linked, so the Manage button only appears on a
2227
+ // connected connection. The app handle is NAMED by `requires` and read from
2228
+ // the env the resolver passes, never from process.env here.
2229
+ manage: (data2, env) => {
2230
+ var _a;
2231
+ const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
2232
+ return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
2233
+ },
783
2234
  // A pre-launch integration: it only surfaces once the App Store listing
784
2235
  // exists and the app is fully configured. Requiring all four means it can
785
2236
  // never render half-configured — and absence of any one excludes the
@@ -790,133 +2241,273 @@ var shopify_default2 = {
790
2241
  "SHOPIFY_APP_LISTING_URL",
791
2242
  "SHOPIFY_APP_HANDLE"
792
2243
  ],
793
- // The only vendor implementing most of the surface, which is why it was the
794
- // one every slug branch in three repos was written for.
795
- //
796
- // auth.probe is false deliberately: the health check re-registers webhooks
797
- // rather than answering "is this token still good", and scope drift is its own
798
- // hook because a token can be perfectly valid while the grant is too narrow.
799
- supports: {
800
- "auth.connect": true,
801
- "auth.disconnect": true,
802
- "auth.probe": false,
803
- "auth.scopes": true,
804
- // Shopify has no separate price resource — a price belongs to a product
805
- // variant and arrives with it, so there is nothing for prices to answer
806
- // that products does not already.
807
- "catalog.audiences": false,
808
- "catalog.prices": false,
809
- "catalog.products": true,
810
- "catalog.promotions": true,
811
- "inbound.event": true,
812
- "inbound.process": true,
813
- "inbound.receive": true,
814
- "inbound.verify": true,
815
- "lifecycle.cleanup": true,
816
- "lifecycle.register": true,
817
- "lifecycle.rehydrate": true
818
- },
819
- setup: [
820
- "Open the Drawbridge listing on the Shopify App Store.",
821
- "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
822
- "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
823
- "Come back here \u2014 the connections list updates on its own once the install lands."
824
- ],
825
2244
  slug: "shopify",
2245
+ // The install is the whole configuration — Shopify hands back the shop and
2246
+ // there is nothing further to choose. `shop` absent means the install did not
2247
+ // finish, which is a credential problem rather than a setup one, so the
2248
+ // stored status already says so.
2249
+ // Nothing to add — no setting can make this connection unusable, so the
2250
+ // credential's own verdict stands.
2251
+ status: (data2) => data2 == null ? void 0 : data2.status,
826
2252
  // Step types name the CAPABILITY, not this vendor. A second store platform
827
2253
  // implements the same four commerce steps, and the connection on the step
828
2254
  // says which store it runs against — so a merchant sees one "Create
829
2255
  // customer", not one per platform. The three connection.* steps are not
830
2256
  // commerce at all: any vendor holding a rotating credential needs them.
2257
+ // Step types name the CAPABILITY, not this vendor. A second store platform
2258
+ // implements the same commerce steps, and the connection on the step says
2259
+ // which store it runs against — so a merchant sees one "Create customer", not
2260
+ // one per platform.
2261
+ //
2262
+ // Nested for readability and flattened to the stored name, at whatever depth:
2263
+ // steps.commerce.customer.insert is `step.commerce.customer.insert` on a
2264
+ // workflow document, and those strings cannot be renamed without a backfill.
2265
+ //
2266
+ // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2267
+ // The bodies these point at still live in drawbridge-sync; moving them is the
2268
+ // next unit, and commerce.order.record is the one that decides whether the
2269
+ // shape holds — 569 lines and 15 controller calls.
831
2270
  steps: {
832
- "step.commerce.customer.insert": {
833
- billable: true,
834
- key: "Create customer",
835
- queue: "connection",
836
- returns: [
837
- { key: "shopifyCustomerId", label: "Shopify Customer ID" }
838
- ],
839
- settings: {},
840
- triggers: ["lead.insert"]
841
- },
842
- "step.commerce.code.issue": {
843
- billable: true,
844
- key: "Issue a discount code",
845
- queue: "connection",
846
- returns: [
847
- { key: "shopifyDiscountCode", label: "Shopify Discount Code" },
848
- { key: "shopifyDiscountId", label: "Shopify Discount ID" }
849
- ],
850
- settings: {
851
- discount: {
852
- required: true,
853
- shape: {
854
- id: { required: true, type: "string" }
2271
+ commerce: {
2272
+ code: {
2273
+ issue: () => ({
2274
+ hook: "commerce.code",
2275
+ key: "Issue a discount code",
2276
+ queue: "connection",
2277
+ settings: {
2278
+ discount: {
2279
+ required: true,
2280
+ shape: {
2281
+ id: { required: true, type: "string" }
2282
+ },
2283
+ type: "object"
2284
+ }
855
2285
  },
856
- type: "object"
857
- }
2286
+ triggers: ["lead.insert"],
2287
+ usage: { actions: 1 }
2288
+ })
858
2289
  },
859
- triggers: ["lead.insert"]
860
- },
861
- // System steps: dispatched by sync itself rather than offered in the
862
- // builder, so they carry no trigger. They are declared because the
863
- // routing table and the system-workflow descriptions both read from here.
864
- // Not a webhook monitor, despite the name it carried. Webhooks are
865
- // declarative — declared in the app's toml, applied by Shopify to every
866
- // install so nothing registers or checks them here. This rotates the
867
- // access token before Shopify's idle window closes, and reconciles the
868
- // scopes the store granted against the ones the app now needs.
869
- "step.connection.health.check": {
870
- description: "Keeps store access working \u2014 refreshes the access token before it goes stale and reports when the store's approved permissions fall behind.",
871
- key: "Shopify Connection Health",
872
- queue: "connection",
873
- system: true
874
- },
875
- "step.commerce.order.record": {
876
- description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
877
- key: "Shopify Order Tracking",
878
- queue: "connection",
879
- system: true
880
- },
881
- "step.commerce.product.sync": {
882
- description: "Syncs Shopify product data on webhook updates.",
883
- key: "Shopify Product Sync",
884
- queue: "connection",
885
- system: true
886
- },
887
- // Audit-only. The "Shopify Token Activity" system workflow lists these for
888
- // descriptive grouping, but its audit step docs are written manually at
889
- // OAuth time — the workflow is never dispatched. Routing is declared
890
- // defensively so that if it ever IS dispatched, the job lands on a real
891
- // queue and the handler lookup misses cleanly instead of throwing
892
- // "Unknown step type".
893
- "step.connection.token.exchange": {
894
- key: "Shopify Token Exchange",
895
- queue: "connection",
896
- system: true
2290
+ customer: {
2291
+ insert: () => ({
2292
+ hook: "commerce.customer",
2293
+ key: "Create customer",
2294
+ queue: "connection",
2295
+ settings: {},
2296
+ triggers: ["lead.insert"],
2297
+ usage: { actions: 1 }
2298
+ })
2299
+ },
2300
+ // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
2301
+ // in the builder, so they carry no trigger and no usage. Declared because
2302
+ // the routing table and the system-workflow descriptions both read here.
2303
+ order: {
2304
+ record: () => ({
2305
+ description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
2306
+ hook: "commerce.order",
2307
+ key: "Shopify Order Tracking",
2308
+ queue: "connection",
2309
+ system: true
2310
+ })
2311
+ },
2312
+ product: {
2313
+ sync: () => ({
2314
+ description: "Syncs Shopify product data on webhook updates.",
2315
+ hook: "commerce.product",
2316
+ key: "Shopify Product Sync",
2317
+ queue: "connection",
2318
+ system: true
2319
+ })
2320
+ }
897
2321
  },
898
- "step.connection.token.refresh": {
899
- key: "Shopify Token Refresh",
900
- queue: "connection",
901
- system: true
2322
+ connection: {
2323
+ // Not a webhook monitor, despite the name it once carried. Webhooks are
2324
+ // declarative — declared in the app's toml, applied by Shopify to every
2325
+ // install — so nothing registers or checks them here. This rotates the
2326
+ // access token before Shopify's idle window closes, and reconciles the
2327
+ // scopes the store granted against the ones the app now needs.
2328
+ health: {
2329
+ check: () => ({
2330
+ description: "Keeps store access working \u2014 refreshes the access token before it goes stale and reports when the store's approved permissions fall behind.",
2331
+ hook: "lifecycle.health",
2332
+ key: "Shopify Connection Health",
2333
+ queue: "connection",
2334
+ system: true
2335
+ })
2336
+ },
2337
+ // Audit-only. The "Shopify Token Activity" system workflow lists these
2338
+ // for descriptive grouping, but its audit step docs are written manually
2339
+ // at OAuth time — the workflow is never dispatched. Routing is declared
2340
+ // defensively so that if it ever IS dispatched, the job lands on a real
2341
+ // queue and the handler lookup misses cleanly instead of throwing
2342
+ // "Unknown step type".
2343
+ token: {
2344
+ exchange: () => ({
2345
+ description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
2346
+ key: "Shopify Token Exchange",
2347
+ queue: "connection",
2348
+ system: true
2349
+ }),
2350
+ refresh: () => ({
2351
+ description: "Records a token rotation. Audit only \u2014 never dispatched.",
2352
+ key: "Shopify Token Refresh",
2353
+ queue: "connection",
2354
+ system: true
2355
+ })
2356
+ }
902
2357
  }
903
2358
  },
2359
+ // Shopify sits pending between the install landing and the merchant choosing a
2360
+ // plan, and nothing on our side can move it — so the card says what they need
2361
+ // to go and do rather than showing Pending with no next step.
2362
+ //
2363
+ // Scope drift is NOT here: drawbridge-sync writes it onto the connection
2364
+ // document, and the document's own warnings render beside these.
2365
+ tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
2366
+ {
2367
+ message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
2368
+ title: "Choose a plan in Shopify"
2369
+ }
2370
+ ] : [],
904
2371
  title: "Shopify"
905
2372
  };
906
2373
 
907
- // lib/connections/icons/drawbridge.js
908
- var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
909
- <rect width="500" height="500" fill="#BAEC5F"/>
910
- <g clip-path="url(#clip0_2115_2832)">
911
- <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"/>
912
- <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"/>
913
- </g>
914
- <defs>
915
- <clipPath id="clip0_2115_2832">
916
- <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
917
- </clipPath>
918
- </defs>
919
- </svg>`;
2374
+ // lib/connections/webhook.js
2375
+ import crypto from "crypto";
2376
+
2377
+ // lib/safe-http.js
2378
+ import dns2 from "dns";
2379
+ import * as http2 from "http";
2380
+ import * as https2 from "https";
2381
+
2382
+ // lib/axios.js
2383
+ import axiosLib from "axios";
2384
+ import dns from "dns";
2385
+ import * as http from "http";
2386
+ import * as https from "https";
2387
+ import net from "net";
2388
+ var dnsLookup = dns.promises.lookup;
2389
+ var isBlockedIPv4 = (ip) => {
2390
+ const parts = ip.split(".").map(Number);
2391
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
2392
+ const [a, b] = parts;
2393
+ if (a === 0) return true;
2394
+ if (a === 10) return true;
2395
+ if (a === 127) return true;
2396
+ if (a === 169 && b === 254) return true;
2397
+ if (a === 172 && b >= 16 && b <= 31) return true;
2398
+ if (a === 192 && b === 168) return true;
2399
+ if (a === 100 && b >= 64 && b <= 127) return true;
2400
+ if (a === 192 && b === 0) return true;
2401
+ if (a === 198 && (b === 18 || b === 19)) return true;
2402
+ if (a === 198 && b === 51) return true;
2403
+ if (a === 203 && b === 0) return true;
2404
+ if (a >= 224) return true;
2405
+ return false;
2406
+ };
2407
+ var isBlockedIPv6 = (ip) => {
2408
+ const lower = ip.toLowerCase();
2409
+ if (lower === "::1" || lower === "::") return true;
2410
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
2411
+ if (/^fe[89ab]/.test(lower)) return true;
2412
+ if (lower.startsWith("ff")) return true;
2413
+ if (lower.startsWith("::ffff:")) {
2414
+ const v4 = lower.slice(7);
2415
+ return isBlockedIPv4(v4);
2416
+ }
2417
+ ;
2418
+ return false;
2419
+ };
2420
+ var isBlockedIP = (ip) => {
2421
+ const version = net.isIP(ip);
2422
+ if (version === 4) return isBlockedIPv4(ip);
2423
+ if (version === 6) return isBlockedIPv6(ip);
2424
+ return true;
2425
+ };
2426
+ var axios = axiosLib.create({
2427
+ timeout: 3e4,
2428
+ httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
2429
+ httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
2430
+ });
2431
+
2432
+ // lib/safe-http.js
2433
+ var DEFAULT_TIMEOUT_MS2 = 15e3;
2434
+ var resolveSafeHost = async (url) => {
2435
+ const parsed = new URL(url);
2436
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
2437
+ throw new Error("Only http(s) URLs are allowed");
2438
+ }
2439
+ const records = await dns2.promises.lookup(parsed.hostname, { all: true });
2440
+ if (!(records == null ? void 0 : records.length)) {
2441
+ throw new Error("Host could not be resolved");
2442
+ }
2443
+ for (const record of records) {
2444
+ if (isBlockedIP(record.address)) {
2445
+ throw new Error("Host resolves to a blocked IP range");
2446
+ }
2447
+ }
2448
+ return { parsed, records };
2449
+ };
2450
+ var pinnedAgent = async (url) => {
2451
+ const { parsed, records } = await resolveSafeHost(url);
2452
+ const pinned = records[0];
2453
+ const lookup2 = (hostname, options, callback) => {
2454
+ if (options == null ? void 0 : options.all) {
2455
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
2456
+ } else {
2457
+ callback(null, pinned.address, pinned.family);
2458
+ }
2459
+ };
2460
+ return {
2461
+ protocol: parsed.protocol,
2462
+ agent: parsed.protocol === "https:" ? new https2.Agent({ lookup: lookup2 }) : new http2.Agent({ lookup: lookup2 })
2463
+ };
2464
+ };
2465
+ var safeRequest = async ({
2466
+ body,
2467
+ headers = {},
2468
+ method = "GET",
2469
+ query,
2470
+ timeout = DEFAULT_TIMEOUT_MS2,
2471
+ type = "json",
2472
+ url
2473
+ }) => {
2474
+ const full = new URL(url);
2475
+ if (query) {
2476
+ Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
2477
+ }
2478
+ const { protocol, agent } = await pinnedAgent(full.toString());
2479
+ const isForm = type === "form";
2480
+ try {
2481
+ const response = await axios({
2482
+ method,
2483
+ url: full.toString(),
2484
+ headers: {
2485
+ "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
2486
+ ...headers
2487
+ },
2488
+ ...body !== void 0 && {
2489
+ data: isForm ? new URLSearchParams(body).toString() : body
2490
+ },
2491
+ timeout,
2492
+ maxRedirects: 0,
2493
+ httpAgent: protocol === "http:" ? agent : void 0,
2494
+ httpsAgent: protocol === "https:" ? agent : void 0,
2495
+ responseType: "json",
2496
+ validateStatus: (status) => status >= 200 && status < 300
2497
+ });
2498
+ return response.data || null;
2499
+ } catch (error) {
2500
+ if (error == null ? void 0 : error.response) {
2501
+ const normalized = new Error(
2502
+ typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
2503
+ );
2504
+ normalized.status = error.response.status;
2505
+ normalized.response = error.response.data;
2506
+ throw normalized;
2507
+ }
2508
+ throw error;
2509
+ }
2510
+ };
920
2511
 
921
2512
  // lib/connections/webhook.js
922
2513
  var webhook_default = {
@@ -931,13 +2522,26 @@ var webhook_default = {
931
2522
  auth: {
932
2523
  type: "generated"
933
2524
  },
934
- category: "developer",
935
- confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
936
- description: [
937
- "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
938
- "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
939
- ],
940
- excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
2525
+ // EVERYTHING A MERCHANT READS. `errors` would belong here too — the
2526
+ // connection DOCUMENT carries its own `errors` array and is spread OVER the
2527
+ // resolved manifest downstream, so a top-level one is replaced by that array.
2528
+ content: {
2529
+ confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
2530
+ description: [
2531
+ "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
2532
+ "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
2533
+ ],
2534
+ excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
2535
+ guide: [
2536
+ "Press Connect. Drawbridge generates a signing secret and shows it here.",
2537
+ "Copy the secret into your own endpoint.",
2538
+ "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
2539
+ ]
2540
+ },
2541
+ // Nothing to be exclusive with — there is no second webhook vendor, and a
2542
+ // merchant with two endpoints is a step-level choice rather than a second
2543
+ // connection.
2544
+ exclusive: false,
941
2545
  feature: "organization:connection:webhook",
942
2546
  fields: [
943
2547
  {
@@ -950,14 +2554,66 @@ var webhook_default = {
950
2554
  label: "Signing secret"
951
2555
  }
952
2556
  ],
2557
+ group: "developer",
2558
+ // OUTBOUND ONLY. inbound.* is false because the direction is the point: we
2559
+ // sign and POST to the merchant's endpoint, they never call us. Every other
2560
+ // false follows from there being no third party to authenticate against —
2561
+ // connect generates a secret rather than proving a credential.
2562
+ hooks: {
2563
+ auth: {
2564
+ // Minting and clearing a secret needs no vendor call, so the api's own
2565
+ // handler does both.
2566
+ connect: {},
2567
+ disconnect: {},
2568
+ probe: false,
2569
+ scopes: false,
2570
+ // Nothing to mint. Connecting generates a secret; there is no vendor.
2571
+ token: false
2572
+ },
2573
+ commerce: false,
2574
+ contacts: { remove: false, sync: false },
2575
+ // Drawbridge sends its own notification email and SMS, and owns its own
2576
+ // segments — see the private `drawbridge` manifest. A vendor answering
2577
+ // these would be a second sender, which is the arrangement the platform
2578
+ // sender replaced.
2579
+ email: false,
2580
+ segment: false,
2581
+ sms: false,
2582
+ inbound: false,
2583
+ lifecycle: false,
2584
+ resources: {
2585
+ audiences: false,
2586
+ prices: false,
2587
+ products: false,
2588
+ promotions: false
2589
+ },
2590
+ // THE BODY IS HERE, not in drawbridge-sync. It needs `crypto` and an HTTP
2591
+ // client and nothing else — no controller, no queue, no database — so
2592
+ // there was never a reason for it to live in another repo.
2593
+ //
2594
+ // That is the rule the whole split runs on: a hook lives in sync only if it
2595
+ // needs Drawbridge's own database, sockets or queues. This one does not.
2596
+ webhook: {
2597
+ send: async ({ context, controller, request: send2 = safeRequest, settings, step }) => {
2598
+ const { headers = {}, method = "POST", url } = step.settings || {};
2599
+ const request2 = { method, url: url || null };
2600
+ if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
2601
+ const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
2602
+ const body = lead || context;
2603
+ request2.body = body;
2604
+ const outgoing = { ...headers };
2605
+ if (settings == null ? void 0 : settings.secret) {
2606
+ outgoing["X-Drawbridge-Signature"] = "sha256=" + crypto.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
2607
+ }
2608
+ const response = await send2({ body, headers: outgoing, method, url });
2609
+ return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
2610
+ }
2611
+ }
2612
+ },
953
2613
  // Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
954
2614
  // It is the one card that reads wrong — our logo among vendor logos — and it
955
2615
  // wants a mark of its own when there is one.
956
2616
  icon: drawbridge_default,
957
- // The destination url is supplied per step, not per connection, so there is
958
- // nothing to finish here — generating the secret IS connecting.
959
- incomplete: () => null,
960
- label: "webhook",
961
2617
  // Gated on the encryption secret: without it the signing secret could not be
962
2618
  // stored safely, so the connection must not be offered at all.
963
2619
  requires: ["ENCRYPT_CONNECTION_SECRET"],
@@ -965,39 +2621,28 @@ var webhook_default = {
965
2621
  // sign and POST to the merchant's endpoint, they never call us. Every other
966
2622
  // false follows from there being no third party to authenticate against —
967
2623
  // connect generates a secret rather than proving a credential.
968
- supports: {
969
- "auth.connect": true,
970
- "auth.disconnect": true,
971
- "auth.probe": false,
972
- "auth.scopes": false,
973
- "catalog.audiences": false,
974
- "catalog.prices": false,
975
- "catalog.products": false,
976
- "catalog.promotions": false,
977
- "inbound.event": false,
978
- "inbound.process": false,
979
- "inbound.receive": false,
980
- "inbound.verify": false,
981
- "lifecycle.cleanup": false,
982
- "lifecycle.register": false,
983
- "lifecycle.rehydrate": false
984
- },
985
- setup: [
986
- "Press Connect. Drawbridge generates a signing secret and shows it here.",
987
- "Copy the secret into your own endpoint.",
988
- "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
989
- ],
990
2624
  slug: "webhook",
2625
+ // The destination url is supplied per step, not per connection, so there is
2626
+ // nothing to finish here — generating the secret IS connecting, and no
2627
+ // setting can make this connection unusable. The credential's own verdict
2628
+ // stands.
2629
+ status: (data2) => data2 == null ? void 0 : data2.status,
991
2630
  steps: {
992
- "step.webhook.send": {
993
- billable: true,
994
- key: "Send webhook",
995
- queue: "webhook",
996
- returns: [],
997
- settings: {
998
- url: { format: "url", required: true, type: "string" }
999
- },
1000
- triggers: ["lead.insert", "lead.delete"]
2631
+ webhook: {
2632
+ send: () => ({
2633
+ hook: "webhook.send",
2634
+ key: "Send webhook",
2635
+ queue: "webhook",
2636
+ settings: {
2637
+ url: { format: "url", required: true, type: "string" }
2638
+ },
2639
+ triggers: ["lead.insert", "lead.delete"],
2640
+ // Replaces `billable : true`, which fed BILLABLE_STEP_TYPES, which set
2641
+ // workflow.billable at save, which sync then checked against a usage
2642
+ // the handler returned — three hops for one fact, two of which could
2643
+ // disagree silently.
2644
+ usage: { actions: 1 }
2645
+ })
1001
2646
  }
1002
2647
  },
1003
2648
  // The card the connection page raises. Before connecting it explains what
@@ -1021,31 +2666,41 @@ var webhook_default = {
1021
2666
 
1022
2667
  // lib/connections/index.js
1023
2668
  var QUEUES = ["connection", "notification", "segment", "webhook"];
2669
+ var implemented = (hooks, path) => {
2670
+ const hook = path.split(".").reduce((node, key) => node == null ? void 0 : node[key], hooks);
2671
+ return typeof hook === "function" || !!hook && typeof hook === "object";
2672
+ };
2673
+ var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
2674
+ ([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
2675
+ );
1024
2676
  var build = (manifest) => {
1025
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
2677
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q;
1026
2678
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
1027
2679
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
1028
- if (!(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
1029
- if (!(manifest == null ? void 0 : manifest.excerpt)) throw new Error(manifest.slug + " needs an excerpt for its card");
1030
- if (!Array.isArray(manifest == null ? void 0 : manifest.description) || !manifest.description.length) {
1031
- throw new Error(manifest.slug + " needs a description \u2014 an array of paragraphs for its page");
2680
+ if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
2681
+ if (!((_a = manifest == null ? void 0 : manifest.content) == null ? void 0 : _a.excerpt)) throw new Error(manifest.slug + " needs content.excerpt for its card");
2682
+ if (!Array.isArray((_b = manifest == null ? void 0 : manifest.content) == null ? void 0 : _b.description) || !manifest.content.description.length) {
2683
+ throw new Error(manifest.slug + " needs content.description \u2014 an array of paragraphs for its page");
1032
2684
  }
1033
- for (const field of manifest.fields || []) {
1034
- if (!(field == null ? void 0 : field.key) || !(field == null ? void 0 : field.label)) {
2685
+ for (const field2 of manifest.fields || []) {
2686
+ if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
1035
2687
  throw new Error(manifest.slug + " declares a field with no key or label");
1036
2688
  }
1037
- if (field.input && !INPUTS.includes(field.input)) {
1038
- throw new Error(manifest.slug + "." + field.key + " declares an unknown input: " + field.input + " \u2014 one of " + INPUTS.join(", "));
2689
+ if (field2.input && !INPUTS.includes(field2.input)) {
2690
+ throw new Error(manifest.slug + "." + field2.key + " declares an unknown input: " + field2.input + " \u2014 one of " + INPUTS.join(", "));
1039
2691
  }
1040
- if (field.input === "select" && !(field.options || []).length && !field.source) {
1041
- throw new Error(manifest.slug + "." + field.key + " is a select and must declare options or a source");
2692
+ if (field2.input === "select" && !(field2.options || []).length && !field2.hook) {
2693
+ throw new Error(manifest.slug + "." + field2.key + " is a select and must declare options or a hook");
1042
2694
  }
1043
- if (field.source) {
1044
- if (!HOOK_NAMES.includes(field.source)) {
1045
- throw new Error(manifest.slug + "." + field.key + " sources options from an unknown hook: " + field.source);
2695
+ if (field2.hook) {
2696
+ if (!HOOK_NAMES.includes(field2.hook)) {
2697
+ throw new Error(manifest.slug + "." + field2.key + " names an unknown hook: " + field2.hook);
1046
2698
  }
1047
- if (((_a = manifest.supports) == null ? void 0 : _a[field.source]) !== true) {
1048
- throw new Error(manifest.slug + "." + field.key + " sources options from " + field.source + ", which it declares unsupported");
2699
+ if (!field2.hook.startsWith("resources.")) {
2700
+ throw new Error(manifest.slug + "." + field2.key + " reads from " + field2.hook + " \u2014 a picker may only read resources.*");
2701
+ }
2702
+ if (!implemented(manifest.hooks, field2.hook)) {
2703
+ throw new Error(manifest.slug + "." + field2.key + " reads " + field2.hook + ", which this vendor does not implement");
1049
2704
  }
1050
2705
  }
1051
2706
  }
@@ -1058,82 +2713,102 @@ var build = (manifest) => {
1058
2713
  if (manifest.icon.includes("<image")) {
1059
2714
  throw new Error(manifest.slug + " icon wraps a raster \u2014 re-export it as vector shapes");
1060
2715
  }
1061
- if (!CATEGORIES.includes(manifest == null ? void 0 : manifest.category)) {
1062
- throw new Error(manifest.slug + " needs a category \u2014 one of " + CATEGORIES.join(", "));
2716
+ if (!GROUPS.includes(manifest == null ? void 0 : manifest.group)) {
2717
+ throw new Error(manifest.slug + " needs a group \u2014 one of " + GROUPS.join(", "));
1063
2718
  }
1064
- if ((_b = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _b.type) {
2719
+ if ((_c = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _c.type) {
1065
2720
  throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
1066
2721
  }
1067
- if (!AUTH_TYPES.includes((_c = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _c.type)) {
2722
+ if (!AUTH_TYPES.includes((_d = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _d.type)) {
1068
2723
  throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
1069
2724
  }
1070
2725
  if (manifest.auth.type === "oauth") {
1071
- for (const field of OAUTH_FIELDS) {
1072
- if (!((_d = manifest.auth.oauth) == null ? void 0 : _d[field])) {
1073
- throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
2726
+ for (const field2 of OAUTH_FIELDS) {
2727
+ if (!((_e = manifest.auth.oauth) == null ? void 0 : _e[field2])) {
2728
+ throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field2);
2729
+ }
2730
+ }
2731
+ if (typeof ((_g = (_f = manifest.hooks) == null ? void 0 : _f.auth) == null ? void 0 : _g.token) !== "function") {
2732
+ throw new Error(manifest.slug + " is oauth and must implement hooks.auth.token \u2014 point it at authToken() or wrap it");
2733
+ }
2734
+ for (const url of OAUTH_URLS) {
2735
+ if (!((_i = (_h = manifest.auth.oauth) == null ? void 0 : _h.urls) == null ? void 0 : _i[url])) {
2736
+ throw new Error(manifest.slug + " is oauth and must declare auth.oauth.urls." + url);
1074
2737
  }
1075
2738
  }
1076
- if (manifest.auth.oauth.redirect !== "/api/connection/" + manifest.slug + "/callback") {
2739
+ if (manifest.auth.oauth.urls.redirect !== "/api/connection/" + manifest.slug + "/callback") {
1077
2740
  throw new Error(
1078
- manifest.slug + " declares auth.oauth.redirect " + manifest.auth.oauth.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
2741
+ manifest.slug + " declares auth.oauth.urls.redirect " + manifest.auth.oauth.urls.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
1079
2742
  );
1080
2743
  }
1081
2744
  }
1082
- if (((_e = manifest.supports) == null ? void 0 : _e["inbound.event"]) && !((_g = (_f = manifest.inbound) == null ? void 0 : _f.headers) == null ? void 0 : _g.event)) {
1083
- throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
2745
+ if (implemented(manifest.hooks, "inbound.event") && !((_k = (_j = manifest.inbound) == null ? void 0 : _j.headers) == null ? void 0 : _k.event)) {
2746
+ throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
1084
2747
  }
1085
- if (((_h = manifest.supports) == null ? void 0 : _h["inbound.verify"]) && !((_j = (_i = manifest.inbound) == null ? void 0 : _i.headers) == null ? void 0 : _j.signature)) {
1086
- throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
2748
+ if (implemented(manifest.hooks, "inbound.verify") && !((_m = (_l = manifest.inbound) == null ? void 0 : _l.headers) == null ? void 0 : _m.signature)) {
2749
+ throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
1087
2750
  }
1088
- if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
1089
- throw new Error(manifest.slug + " must declare incomplete( data ) \u2014 return null when the connection is usable, or the reason it is not");
2751
+ if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
2752
+ 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");
1090
2753
  }
1091
- if (!Array.isArray(manifest == null ? void 0 : manifest.setup) || !manifest.setup.length) {
1092
- throw new Error(manifest.slug + " needs a setup guide \u2014 an array of steps for its page");
2754
+ if (!Array.isArray((_n = manifest == null ? void 0 : manifest.content) == null ? void 0 : _n.guide) || !manifest.content.guide.length) {
2755
+ throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
1093
2756
  }
1094
- for (const [name, hook] of Object.entries(manifest.hooks || {})) {
1095
- if (!HOOK_NAMES.includes(name)) {
1096
- throw new Error(manifest.slug + " implements an unknown hook: " + name);
1097
- }
1098
- if (typeof hook !== "function") {
1099
- throw new Error(manifest.slug + " declares hook " + name + " but it is not a function");
1100
- }
1101
- if (((_k = manifest.supports) == null ? void 0 : _k[name]) !== true) {
1102
- throw new Error(manifest.slug + " implements " + name + " but declares supports[ '" + name + "' ] false");
1103
- }
2757
+ if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
2758
+ throw new Error(manifest.slug + " must declare status( data ) \u2014 one of " + STATUSES.join(", "));
1104
2759
  }
1105
- const supports = manifest.supports || {};
1106
- for (const name of HOOK_NAMES) {
1107
- if (typeof supports[name] !== "boolean") {
1108
- throw new Error(manifest.slug + " must declare supports[ '" + name + "' ] as true or false");
2760
+ const status = manifest.status({});
2761
+ if (status != null && !STATUSES.includes(status)) {
2762
+ throw new Error(manifest.slug + " status() returned " + status + " \u2014 must be one of " + STATUSES.join(", "));
2763
+ }
2764
+ for (const [domain, verbs] of Object.entries(HOOKS)) {
2765
+ for (const verb of verbs) {
2766
+ const hook = (_p = (_o = manifest.hooks) == null ? void 0 : _o[domain]) == null ? void 0 : _p[verb];
2767
+ if (((_q = manifest.hooks) == null ? void 0 : _q[domain]) === false) continue;
2768
+ if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
2769
+ throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
2770
+ }
1109
2771
  }
1110
2772
  }
1111
- for (const name of Object.keys(supports)) {
1112
- if (!HOOK_NAMES.includes(name)) {
1113
- throw new Error(manifest.slug + " declares an unknown hook: " + name);
2773
+ for (const [domain, verbs] of Object.entries(manifest.hooks || {})) {
2774
+ if (!HOOKS[domain]) throw new Error(manifest.slug + " implements an unknown hook domain: " + domain);
2775
+ for (const verb of Object.keys(verbs === false ? {} : verbs)) {
2776
+ if (!HOOKS[domain].includes(verb)) {
2777
+ throw new Error(manifest.slug + " implements an unknown hook: " + domain + "." + verb);
2778
+ }
1114
2779
  }
1115
2780
  }
1116
- for (const [type, step] of Object.entries(manifest.steps || {})) {
1117
- if (!type.startsWith("step.")) {
1118
- throw new Error(manifest.slug + " declares a step type that is not step.<domain>.<verb>: " + type);
2781
+ for (const [name, step] of leaves(manifest.steps)) {
2782
+ const type = "step." + name;
2783
+ if (!STEPS[name]) {
2784
+ throw new Error(manifest.slug + " declares an unknown step: " + type + " \u2014 add it to STEPS in contract.js");
1119
2785
  }
1120
- if (!(step == null ? void 0 : step.key)) throw new Error(manifest.slug + " step " + type + " needs a key \u2014 the label the builder shows");
1121
- if (!QUEUES.includes(step == null ? void 0 : step.queue)) {
2786
+ const declared = step({});
2787
+ if (!(declared == null ? void 0 : declared.key)) throw new Error(manifest.slug + " step " + type + " needs a key \u2014 the label the builder shows");
2788
+ if (!QUEUES.includes(declared == null ? void 0 : declared.queue)) {
1122
2789
  throw new Error(manifest.slug + " step " + type + " needs a queue \u2014 one of " + QUEUES.join(", "));
1123
2790
  }
2791
+ if (declared.hook && !implemented(manifest.hooks, declared.hook)) {
2792
+ throw new Error(manifest.slug + " step " + type + " points at hook " + declared.hook + ", which this vendor does not implement");
2793
+ }
1124
2794
  }
1125
2795
  return Object.freeze({
1126
2796
  ...manifest,
1127
2797
  fields: Object.freeze(manifest.fields || []),
1128
2798
  hooks: Object.freeze(manifest.hooks || {}),
1129
2799
  inbound: Object.freeze(manifest.inbound || {}),
1130
- setup: Object.freeze(manifest.setup || []),
1131
- supports: Object.freeze(supports),
1132
2800
  requires: Object.freeze(manifest.requires || []),
1133
2801
  steps: Object.freeze(manifest.steps || {})
1134
2802
  });
1135
2803
  };
2804
+ var stepLabels = (catalog = connections) => Object.fromEntries(
2805
+ Object.values(catalog).flatMap(
2806
+ (vendor) => leaves(vendor.steps).map(([name]) => ["step." + name, STEP_LABELS["step." + name]])
2807
+ )
2808
+ );
1136
2809
  var connections = Object.freeze({
2810
+ drawbridge: build(drawbridge_default2),
2811
+ hubspot: build(hubspot_default),
1137
2812
  klaviyo: build(klaviyo_default2),
1138
2813
  mailchimp: build(mailchimp_default2),
1139
2814
  shopify: build(shopify_default2),
@@ -1142,7 +2817,8 @@ var connections = Object.freeze({
1142
2817
  (() => {
1143
2818
  const owners = {};
1144
2819
  for (const [slug, manifest] of Object.entries(connections)) {
1145
- for (const type of Object.keys(manifest.steps)) {
2820
+ for (const [name] of leaves(manifest.steps)) {
2821
+ const type = "step." + name;
1146
2822
  if (owners[type]) {
1147
2823
  throw new Error("Step " + type + " is declared by both " + owners[type] + " and " + slug);
1148
2824
  }
@@ -1155,23 +2831,27 @@ var availableConnections = (env = {}) => Object.fromEntries(
1155
2831
  ([, manifest]) => manifest.requires.every((name) => Boolean(env[name]))
1156
2832
  )
1157
2833
  );
2834
+ var catalogConnections = (env = {}) => Object.fromEntries(
2835
+ Object.entries(availableConnections(env)).filter(([, manifest]) => !manifest.private)
2836
+ );
1158
2837
  var publicSettingsBySlug = Object.fromEntries(
1159
2838
  Object.entries(connections).map(([slug, manifest]) => [
1160
2839
  slug,
1161
- manifest.fields.filter((field) => !field.redact).map((field) => field.key)
2840
+ manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
1162
2841
  ])
1163
2842
  );
1164
2843
  var connectionSteps = (env = {}) => Object.entries(availableConnections(env)).flatMap(
1165
- ([slug, manifest]) => Object.entries(manifest.steps).map(([type, step]) => ({ ...step, slug, type }))
2844
+ ([slug, manifest]) => leaves(manifest.steps).map(([name, step]) => ({ ...step({}), slug, type: "step." + name }))
1166
2845
  );
1167
2846
  var hookSupport = (name) => ({
1168
- no: Object.keys(connections).filter((slug) => !connections[slug].supports[name]),
1169
- yes: Object.keys(connections).filter((slug) => connections[slug].supports[name])
2847
+ no: Object.keys(connections).filter((slug) => !implemented(connections[slug].hooks, name)),
2848
+ yes: Object.keys(connections).filter((slug) => implemented(connections[slug].hooks, name))
1170
2849
  });
1171
2850
  var connectFields = (slug) => {
1172
2851
  var _a;
1173
- return (((_a = connections[slug]) == null ? void 0 : _a.fields) || []).map(({ copy, from, input, key, label, message, options, placeholder, redact, required }) => ({
2852
+ return (((_a = connections[slug]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
1174
2853
  ...copy && { copy: true },
2854
+ ...hook && { hook },
1175
2855
  ...from && { from },
1176
2856
  ...input && { input },
1177
2857
  key,
@@ -1179,6 +2859,9 @@ var connectFields = (slug) => {
1179
2859
  ...message && { message },
1180
2860
  ...options && { options },
1181
2861
  ...placeholder && { placeholder },
2862
+ // Declared false only where the vendor cannot filter, so a picker does not
2863
+ // offer a search box that quietly searches one page.
2864
+ ...search === false && { search: false },
1182
2865
  required: Boolean(required),
1183
2866
  // A UI hint, not a leak: the form uses it to stop requiring the field once
1184
2867
  // the connection exists, and to say "leave blank to keep" — because the GET
@@ -1187,16 +2870,17 @@ var connectFields = (slug) => {
1187
2870
  }));
1188
2871
  };
1189
2872
  var runHook = async (slug, name, args = {}) => {
1190
- var _a, _b;
1191
2873
  const manifest = connections[slug];
1192
2874
  if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug };
1193
- if (!((_a = manifest.supports) == null ? void 0 : _a[name])) return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
1194
- const hook = (_b = manifest.hooks) == null ? void 0 : _b[name];
2875
+ const hook = name.split(".").reduce((node, key) => node == null ? void 0 : node[key], manifest.hooks);
2876
+ if (hook === false || hook == null) {
2877
+ return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
2878
+ }
1195
2879
  if (typeof hook !== "function") {
1196
2880
  return { outcome: OUTCOMES.unimplemented, reason: slug + " implements " + name + " outside this package" };
1197
2881
  }
1198
2882
  try {
1199
- return { outcome: OUTCOMES.answered, result: await hook(args) };
2883
+ return { outcome: OUTCOMES.answered, result: await hook({ ...args, manifest }) };
1200
2884
  } catch (error) {
1201
2885
  return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
1202
2886
  }
@@ -1226,22 +2910,26 @@ var redactSettings = ({ slug, settings }) => {
1226
2910
  };
1227
2911
  var publicConnectionKeys = Object.freeze([
1228
2912
  "actions",
1229
- "category",
1230
- "confirm",
2913
+ // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
2914
+ // auth.type, content.redirect and the manifest's manage() — the client reads
2915
+ // connect.type to choose entered-vs-installed, connect.redirect for the App
2916
+ // Store link, connect.manage for the admin deep link. It was dropped from
2917
+ // this list when the manifests stopped declaring it, which stripped the
2918
+ // composed object from every response and broke all three.
1231
2919
  "connect",
2920
+ // EVERYTHING A MERCHANT READS, in one key: confirm, description, errors,
2921
+ // excerpt, guide, and any vendor redirect copy.
2922
+ "content",
1232
2923
  "createdAt",
2924
+ // The connection DOCUMENT's own errors array — scope-drift entries written by
2925
+ // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
2926
+ // the document is spread OVER the resolved manifest downstream, so the two
2927
+ // sharing this key means the array silently wins.
1233
2928
  "errors",
1234
- "description",
1235
- "excerpt",
1236
2929
  "fields",
1237
2930
  "group",
1238
2931
  "id",
1239
2932
  "image",
1240
- // The reason a connected vendor still is not usable — a Klaviyo grant with no
1241
- // list chosen. Public because the card that shows Pending has to say why.
1242
- "incomplete",
1243
- "label",
1244
- "setup",
1245
2933
  "settings",
1246
2934
  "shop",
1247
2935
  "slug",
@@ -1264,31 +2952,36 @@ var projectConnection = (record) => {
1264
2952
  {}
1265
2953
  );
1266
2954
  };
1267
- var resolveConnection = (item, data) => {
2955
+ var resolveConnection = (item, data2, env = {}) => {
1268
2956
  if (!item) return item;
1269
2957
  return Object.fromEntries(
1270
2958
  Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
1271
2959
  key,
1272
- typeof value === "function" ? value(data) : value
2960
+ typeof value === "function" ? value(data2, env) : value
1273
2961
  ])
1274
2962
  );
1275
2963
  };
1276
2964
  export {
1277
2965
  AUTH_TYPES,
1278
- CATEGORIES,
2966
+ GROUPS,
1279
2967
  HOOKS,
1280
2968
  HOOK_NAMES,
1281
2969
  INPUTS,
1282
2970
  OAUTH_FIELDS,
1283
2971
  OUTCOMES,
2972
+ RETIRED,
2973
+ STATUSES,
2974
+ STEPS,
2975
+ STEP_TYPES,
1284
2976
  accessToken,
2977
+ authToken,
1285
2978
  availableConnections,
1286
2979
  build,
2980
+ catalogConnections,
1287
2981
  connectFields,
1288
2982
  connectionSteps,
1289
2983
  connections,
1290
2984
  consentUrl,
1291
- exchange,
1292
2985
  hookSupport,
1293
2986
  isStale,
1294
2987
  mergeSettings,
@@ -1297,10 +2990,10 @@ export {
1297
2990
  publicConnectionKeys,
1298
2991
  publicSettingsBySlug,
1299
2992
  redactSettings,
1300
- refresh,
1301
2993
  resolveConnection,
1302
2994
  runHook,
1303
2995
  scopesMessage,
2996
+ stepLabels,
1304
2997
  stepQueues,
1305
2998
  tokenSettings
1306
2999
  };