@drawbridge/drawbridge-utils 0.0.112 → 0.0.115

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