@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,6 +1,26 @@
1
- import { refresh } from './oauth.cjs';
2
- export { consentUrl, exchange, pkcePair } from './oauth.cjs';
3
- import { createHmac, timingSafeEqual } from 'node:crypto';
1
+ import { authToken } from './oauth.cjs';
2
+ export { consentUrl, pkcePair } from './oauth.cjs';
3
+ import { channels } from '../pricing.cjs';
4
+ import { request } from '../http.cjs';
5
+ import crypto, { createHmac, timingSafeEqual } from 'node:crypto';
6
+ import { safeRequest } from '../safe-http.cjs';
7
+ import '../plans.cjs';
8
+ import '../features.cjs';
9
+ import '../index.cjs';
10
+ import 'currency-codes';
11
+ import 'nanoid';
12
+ import '../color.cjs';
13
+ import 'tinycolor2';
14
+ import '../usage.cjs';
15
+ import '../billing.cjs';
16
+ import '../transactions.cjs';
17
+ import '@drawbridge/drawbridge-telemetry';
18
+ import 'dns';
19
+ import 'node:http';
20
+ import 'node:https';
21
+ import '../axios.cjs';
22
+ import 'axios';
23
+ import 'net';
4
24
 
5
25
  // THE HOOK VOCABULARY. Closed, and every connection answers all of it.
6
26
  //
@@ -34,11 +54,26 @@ const HOOKS = Object.freeze({
34
54
  // the credential can be valid and the grant still be too narrow.
35
55
  'scopes',
36
56
  // Revoke at the vendor and drop what we hold.
37
- 'disconnect'
57
+ 'disconnect',
58
+ // MINT A TOKEN — from a consent code, or from a stored refresh token. Both
59
+ // are the same POST, so they are one hook.
60
+ //
61
+ // The default body is authToken() in oauth.js and most vendors point
62
+ // straight at it. It is a hook rather than a declared flag because the
63
+ // vendors that differ, differ in ways config cannot express: Klaviyo needs
64
+ // HTTP Basic where others want body fields, and Mailchimp cannot use the
65
+ // token it receives until a second call tells it which data centre the
66
+ // account is behind.
67
+ 'token'
38
68
  ]),
39
69
 
40
70
  // What happens around connecting and disconnecting, beyond the credential.
41
71
  lifecycle : Object.freeze([
72
+ // KEEP ACCESS WORKING. Rotate a credential before its window closes, prove
73
+ // it still works, and reconcile whatever the vendor has changed underneath
74
+ // — scopes, webhooks. Distinct from auth.probe, which only answers "is this
75
+ // still good": this one FIXES what it can and reports what it cannot.
76
+ 'health',
42
77
  // Post-connect setup: register the vendor's webhooks, create the system
43
78
  // workflows that describe them.
44
79
  'register',
@@ -94,9 +129,69 @@ const HOOKS = Object.freeze({
94
129
  'process'
95
130
  ]),
96
131
 
97
- // Vendor data a campaign draws on. Named for what every store platform has,
98
- // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
99
- // and promotion codes, BigCommerce says coupons and promotions.
132
+ // WHAT A WORKFLOW STEP DOES, as a verb like any other. Step handlers used to
133
+ // live in drawbridge-sync keyed by step type, which meant a vendor's logic sat
134
+ // in a repo the vendor file could not see — the split this whole exercise
135
+ // exists to close.
136
+ //
137
+ // A `steps` entry points at one of these; the step says when it runs and what
138
+ // it costs, the hook does the work.
139
+ contacts : Object.freeze([
140
+ // Forget a contact at the vendor. Distinct from suppression, which keeps the
141
+ // record and marks it unsubscribed: this is deletion, for account closure.
142
+ 'remove',
143
+ // Push one contact into the audience the merchant chose, honouring
144
+ // suppression rather than omitting an opted-out person — omission lets them
145
+ // quietly reappear on the next sync.
146
+ 'sync'
147
+ ]),
148
+
149
+ commerce : Object.freeze([
150
+ // Mint a discount code against this merchant's store, mapped to one lead.
151
+ 'code',
152
+ // Create the buyer at the vendor, so an order can be attributed to them.
153
+ 'customer',
154
+ // An order arrived at the vendor: attribute it, record it, update totals.
155
+ 'order',
156
+ // Pull product data across on a vendor update.
157
+ 'product'
158
+ ]),
159
+
160
+ // WHAT DRAWBRIDGE ITSELF DOES. These are not a third party's verbs — nobody
161
+ // connects an account to send email through Drawbridge — but they are steps a
162
+ // workflow runs, and a step points at a hook. So they live on a PRIVATE
163
+ // connection: one that contributes steps and never appears in the catalog.
164
+ //
165
+ // Without it the base steps stay the exception the shell has to know about,
166
+ // and "every step is a declaration pointing at a hook" stops being true the
167
+ // moment somebody looks at the six most common ones.
168
+ email : Object.freeze([
169
+ // To a lead. Suppression applies, and the send is billed.
170
+ 'send',
171
+ // To organization members. Never suppressed — an entrant's opt-out must not
172
+ // silence an alert to staff — and never billed.
173
+ 'notify',
174
+ // A batched summary to members.
175
+ 'digest'
176
+ ]),
177
+
178
+ sms : Object.freeze([ 'send' ]),
179
+
180
+ segment : Object.freeze([ 'sync' ]),
181
+
182
+ // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
183
+ // The Webhooks connection is the only thing here with no third party behind
184
+ // it, and the destination is per STEP rather than per connection.
185
+ webhook : Object.freeze([ 'send' ]),
186
+
187
+ // Vendor data we READ — the things a merchant picks from. Named `resources`
188
+ // rather than `catalog` because it also holds audiences, and a catalog is a
189
+ // commerce word for something that is not only commerce. It matches the
190
+ // pickers that render it, too: InputResource and ListResource.
191
+ //
192
+ // The verbs are named for what every vendor has, not for what one calls it:
193
+ // Shopify says discounts, Stripe says coupons and promotion codes,
194
+ // BigCommerce says coupons and promotions.
100
195
  //
101
196
  // ONE SHAPE FOR ALL OF THEM — searchable and cursor-paged:
102
197
  //
@@ -116,7 +211,7 @@ const HOOKS = Object.freeze({
116
211
  // Shopify pushes the term into its GraphQL query, Klaviyo has a filter
117
212
  // parameter, and Mailchimp's /lists has no name filter at all so its hook
118
213
  // matches against what it fetched. The caller never learns which.
119
- catalog : Object.freeze([
214
+ resources : Object.freeze([
120
215
  // The named groups a contact can be synced INTO. Klaviyo calls them lists,
121
216
  // Mailchimp calls them audiences; `audiences` is the industry-generic term
122
217
  // and belongs to neither vendor's API. Read at form time, so a merchant
@@ -133,6 +228,78 @@ const HOOKS = Object.freeze({
133
228
 
134
229
  });
135
230
 
231
+ // WHAT A WORKFLOW STEP CAN BE. Closed, and the LABEL LIVES HERE rather than on a
232
+ // vendor, because a step type belongs to the capability and not to whoever
233
+ // implements it: Klaviyo and Mailchimp both do contacts.sync, and a label taken
234
+ // from either would give a merchant two identically-named builder entries — the
235
+ // exact mistake step.shopify.* made before the rename to step.commerce.*
236
+ //
237
+ // KEYED BY THE FULL NAME, not nested, because these strings are STORED on
238
+ // workflow documents and several are three segments deep
239
+ // (commerce.customer.insert). A manifest still nests its `steps` for
240
+ // readability; the path is joined to produce these keys, at whatever depth the
241
+ // vendor wrote it. Renaming any of them is a backfill, not an edit.
242
+ //
243
+ // A manifest's own `key` is the INSTANCE label ("Sync contact to Acme Co"),
244
+ // which is a different sentence for a different place.
245
+ //
246
+ // drawbridge-api's enums.step.type — the $jsonSchema validator on the workflow
247
+ // collection — UNIONS this over its own map rather than replacing it. A stored
248
+ // document carrying a retired type must stay writable, including by the
249
+ // migration that retires it.
250
+ const STEPS = Object.freeze({
251
+ 'commerce.code.issue' : 'Issue discount code',
252
+ 'commerce.customer.insert' : 'Create customer',
253
+ 'commerce.order.record' : 'Record order',
254
+ 'commerce.product.sync' : 'Sync product',
255
+ // Not commerce at all — connection lifecycle, and they generalise to any
256
+ // vendor holding a rotating credential.
257
+ 'connection.health.check' : 'Connection health check',
258
+ 'connection.token.exchange' : 'Exchange token',
259
+ 'connection.token.refresh' : 'Refresh token',
260
+ 'contacts.sync' : 'Sync contact',
261
+ 'email.digest' : 'Digest',
262
+ 'email.notify' : 'Notification',
263
+ 'email.send' : 'Send email',
264
+ 'segment.sync' : 'Sync segment',
265
+ 'sms.send' : 'Send SMS',
266
+ 'webhook.send' : 'Send webhook'
267
+ });
268
+
269
+ // WHAT EACH RETIRED STEP TYPE BECAME.
270
+ //
271
+ // `step.shopify.*` was renamed to `step.commerce.*` because a step type belongs
272
+ // to the CAPABILITY and not to whoever implements it — a second store platform
273
+ // does the same four things, and vendor-namespaced types would have shown a
274
+ // merchant two "Create customer" entries.
275
+ //
276
+ // Stored documents still carry the old names until the backfill runs, so both
277
+ // must keep working. This is the one place that says which is which: the queue
278
+ // resolves a legacy type through it to find the manifest that declares its
279
+ // replacement, and drawbridge-api's enums.step.type keeps every key here in its
280
+ // validator so those documents stay writable — including by the migration that
281
+ // retires them.
282
+ //
283
+ // A type is removed from here a release AFTER its backfill, never with it.
284
+ const RETIRED = Object.freeze({
285
+ 'step.shopify.customer.insert' : 'step.commerce.customer.insert',
286
+ 'step.shopify.discount.update' : 'step.commerce.code.issue',
287
+ 'step.shopify.health.check' : 'step.connection.health.check',
288
+ 'step.shopify.order.record' : 'step.commerce.order.record',
289
+ 'step.shopify.product.sync' : 'step.commerce.product.sync',
290
+ 'step.shopify.token.exchange' : 'step.connection.token.exchange',
291
+ 'step.shopify.token.refresh' : 'step.connection.token.refresh'
292
+ });
293
+
294
+ // 'step.<name>' — the string stored on a workflow document.
295
+ const STEP_TYPES = Object.freeze( Object.keys( STEPS ).map( ( name ) => 'step.' + name ) );
296
+
297
+ // The same vocabulary as { 'step.<name>' : label }, the shape enums.step.type
298
+ // is written in.
299
+ const STEP_LABELS = Object.freeze( Object.fromEntries(
300
+ Object.entries( STEPS ).map( ( [ name, label ] ) => [ 'step.' + name, label ] )
301
+ ) );
302
+
136
303
  // Flat 'domain.verb' names, which is how a manifest declares support and how a
137
304
  // caller asks for one.
138
305
  const HOOK_NAMES = Object.freeze(
@@ -154,6 +321,22 @@ const OUTCOMES = Object.freeze({
154
321
  unsupported : 'unsupported'
155
322
  });
156
323
 
324
+ // WHAT A CONNECTION'S STATE IS. A connection's `status()` returns one of these
325
+ // and nothing else — the SENTENCE explaining a state belongs in `tasks`, which
326
+ // already exists to carry merchant-facing copy and is already rendered.
327
+ //
328
+ // Mirrors enums.connection.status in drawbridge-api, which is the $jsonSchema
329
+ // validator on the connection collection — a value not in that enum is rejected
330
+ // by the DATABASE with "Document failed validation", the opaque error a missing
331
+ // `feature` field already cost an hour to trace. Declared here because
332
+ // drawbridge-sync cannot import the api, and a copy in each is the two-repo trap
333
+ // this package exists to close.
334
+ //
335
+ // NO `incomplete`. That is an ORGANIZATION status meaning outstanding invoices,
336
+ // and reusing the word for a connection would leave nobody able to say which
337
+ // object a status referred to.
338
+ const STATUSES = Object.freeze([ 'active', 'disconnected', 'error', 'pending' ]);
339
+
157
340
  // HOW A VENDOR IS CONNECTED. Four kinds, because the merchant's experience of
158
341
  // each is genuinely different and the dashboard renders from this:
159
342
  //
@@ -166,12 +349,17 @@ const OUTCOMES = Object.freeze({
166
349
  // merchant never sees a consent screen we sent them to — they start at the
167
350
  // vendor's app store — and a dashboard that offers a Connect button for one is
168
351
  // lying about the other.
169
- const AUTH_TYPES = Object.freeze([ 'generated', 'install', 'keys', 'oauth' ]);
352
+ // `none` is the private case: Drawbridge sending its own email authenticates
353
+ // against nothing, and there is no merchant action that could connect it.
354
+ const AUTH_TYPES = Object.freeze([ 'generated', 'install', 'keys', 'none', 'oauth' ]);
170
355
 
171
356
  // WHAT KIND OF VENDOR THIS IS. Closed, because a card list that works at three
172
- // connections stops working the day every CMS joins it, and a category nobody
357
+ // connections stops working the day every CMS joins it, and a group nobody
173
358
  // spelled right is a filter chip that silently shows nothing.
174
- const CATEGORIES = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging' ]);
359
+ //
360
+ // One word for one fact. `category` used to sit beside `group` meaning the same
361
+ // thing, which left nobody able to say which one a page read.
362
+ const GROUPS = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging' ]);
175
363
 
176
364
  // WHAT AN OAUTH VENDOR MUST DECLARE, so one implementation serves all of them
177
365
  // rather than one module per vendor.
@@ -187,7 +375,11 @@ const CATEGORIES = Object.freeze([ 'commerce', 'contacts', 'developer', 'messagi
187
375
  // params vendor quirks (Google wants access_type=offline)
188
376
  // pkce Klaviyo's OAuth 2.1 REQUIRES a code_verifier/code_challenge pair;
189
377
  // most vendors do not. A flag rather than a Klaviyo module.
190
- const OAUTH_FIELDS = Object.freeze([ 'authorize', 'client', 'redirect', 'token' ]);
378
+ // `client` sits on auth.oauth; the rest are addresses and live under
379
+ // auth.oauth.urls with every other vendor URL, so `revoke` stopped being a
380
+ // literal buried in the disconnect hook.
381
+ const OAUTH_FIELDS = Object.freeze([ 'client' ]);
382
+ const OAUTH_URLS = Object.freeze([ 'authorize', 'redirect', 'token' ]);
191
383
 
192
384
  // HOW A FIELD IS EDITED. Closed, because the dashboard renders one form for
193
385
  // every connection and it can only render what it has a component for. An open
@@ -307,7 +499,11 @@ const accessToken = async ({
307
499
 
308
500
  }
309
501
 
310
- const minted = await refresh({
502
+ // THE VENDOR'S OWN auth.token, called directly rather than through runHook.
503
+ // This runs inside hooks — a step hook awaits accessToken before its vendor
504
+ // call — so going through the outcome wrapper would be re-entrant and would
505
+ // wrap an error that attempt() already handles.
506
+ const minted = await manifest.hooks.auth.token({
311
507
  clientId,
312
508
  clientSecret,
313
509
  descriptor : manifest.auth.oauth,
@@ -323,33 +519,844 @@ const accessToken = async ({
323
519
 
324
520
  };
325
521
 
326
- // Klaviyo, exported from the brand kit and left as authored — the fills are the
522
+ // Attentive, exported from the brand kit and left as authored — the yellow
523
+ // field and the mark's fills are the vendor's own, not a recolour.
524
+ //
525
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
526
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
527
+ // tests through dist.
528
+ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
529
+ <rect width="500" height="500" fill="#FFD967"/>
530
+ <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"/>
531
+ <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"/>
532
+ </svg>`;
533
+
534
+ // Attentive — SMS-first marketing, installed as a distributed Attentive app.
535
+ //
536
+ // EVERY VENDOR FACT BELOW IS CITED from docs.attentive.com (fetched 2026-09-01):
537
+ //
538
+ // authorize docs.attentive.com/docs/authentication — the install prompt is
539
+ // ui.attentivemobile.com/integrations/oauth-install, documented
540
+ // with `client_id` and `redirect_uri` parameters.
541
+ //
542
+ // token docs.attentive.com/reference/createtokenviaauthorizationcode.md —
543
+ // POST api.attentivemobile.com/v1/authorization-codes/tokens, all
544
+ // parameters as FORM FIELDS (grant_type, code, redirect_uri,
545
+ // client_id, client_secret). Credentials in the body is our
546
+ // runner's default, so unlike Klaviyo there is nothing to wrap
547
+ // for auth — see hooks.auth.token for what IS wrapped, and why.
548
+ //
549
+ // response { access_token, id_token, token_type : "Bearer",
550
+ // expires_in : 900 } — and NO refresh_token, which matters below.
551
+ //
552
+ // scopes events:write, ecommerce:write, subscriptions:write,
553
+ // attributes:write, privacy_requests:write; the segments API
554
+ // takes segments:read or segments:write. Scopes ride on the app's
555
+ // registration (the generated distribution URL carries them), not
556
+ // on each consent — so none are declared here.
557
+ //
558
+ // segments docs.attentive.com/reference/listsegments.md —
559
+ // GET api.attentivemobile.com/v2/segments with `name` (partial
560
+ // match), `cursor` and `limit` (1-1000, default 20); responds
561
+ // { segments : [ { externalId, name, ... } ], cursor, hasMore }.
562
+ //
563
+ // DORMANT UNTIL REGISTERED. `requires` names the client credentials that only
564
+ // exist once the Attentive app is created (their console: enable distribution,
565
+ // set the redirect URL, Generate Credentials). Until those env vars land, no
566
+ // deployment offers this connection — the manifest ships complete and inert.
567
+ //
568
+ // THREE THINGS TO VERIFY AT REGISTRATION, because the docs conflict or are
569
+ // silent, and only a live install answers them:
570
+ //
571
+ // 1. TOKEN LIFETIME. The auth overview says access tokens "do not expire";
572
+ // the token response carries expires_in : 900 and no refresh_token. If
573
+ // the token really died at 15 minutes with nothing to renew it, every
574
+ // connection would need reconnecting a quarter-hour after consent.
575
+ // hooks.auth.token below models the overview's answer — see its comment.
576
+ //
577
+ // 2. STATE ECHO. Our callback requires the parked `state` to round-trip, and
578
+ // their authorize URL is documented with only client_id and redirect_uri.
579
+ // If the consent drops unknown params, the flow breaks at the callback.
580
+ //
581
+ // 3. PER-ENVIRONMENT REDIRECTS. The redirect must byte-match the app's
582
+ // registration, and dev and prod have different callback hosts — one app
583
+ // with multiple redirect URLs, or two apps like Shopify's two tomls.
584
+ var attentive = {
585
+ auth : {
586
+ oauth : {
587
+ // NAMES of the env vars holding OUR app's client — set at registration,
588
+ // never before. No `headers` on the client: Attentive takes credentials
589
+ // as form fields, which is the runner's default.
590
+ client : {
591
+ id : 'ATTENTIVE_OAUTH_CLIENT_ID',
592
+ secret : 'ATTENTIVE_OAUTH_CLIENT_SECRET'
593
+ },
594
+ urls : {
595
+ authorize : 'https://ui.attentivemobile.com/integrations/oauth-install',
596
+ redirect : '/api/connection/attentive/callback',
597
+ token : 'https://api.attentivemobile.com/v1/authorization-codes/tokens'
598
+ }
599
+ },
600
+ type : 'oauth'
601
+ },
602
+ // EVERYTHING A MERCHANT READS. `errors` would live in here too — the
603
+ // connection DOCUMENT carries its own `errors` array and is spread OVER the
604
+ // resolved manifest downstream, so a top-level one would never render.
605
+ content : {
606
+ confirm : 'Disconnecting revokes Drawbridge\'s access to your Attentive account. Your subscribers stay in both Attentive and Drawbridge — neither list is deleted.',
607
+ description : [
608
+ 'Attentive is where your SMS marketing lives, and this connection is becoming the way your Drawbridge contacts sync into an Attentive segment.',
609
+ 'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
610
+ 'Subscriber syncing is not live yet, so connecting today does nothing except choose the segment it will use when it ships.'
611
+ ],
612
+ excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
613
+ guide : [
614
+ 'Press Connect. Drawbridge sends you to Attentive to approve access.',
615
+ 'Sign in to Attentive if you are not already, and authorize the permissions listed.',
616
+ 'You are returned here — choose which Attentive segment your contacts should sync into.'
617
+ ]
618
+ },
619
+ // A contact destination, like Klaviyo and Mailchimp — a merchant could
620
+ // reasonably keep several up to date at once.
621
+ exclusive : false,
622
+ feature : 'organization:connection:attentive',
623
+ fields : [
624
+ {
625
+ input : 'select',
626
+ key : 'segment',
627
+ label : 'Attentive segment',
628
+ message : 'Contacts your campaigns collect are synced into this segment.',
629
+ hook : 'resources.audiences',
630
+ required : true
631
+ // No `search : false` here, and that is a first: /v2/segments takes a
632
+ // `name` filter (partial match, cited above), so this picker searches
633
+ // the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
634
+ }
635
+ ],
636
+ group : 'contacts',
637
+ // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
638
+ // nothing else is built yet, because subscriber sync has not shipped. Every
639
+ // false here is "not yet" rather than "never" — when the sync lands, probe
640
+ // and contacts.sync are the first to flip.
641
+ hooks : {
642
+
643
+ auth : {
644
+ // The exchange already yields the tokens, and Attentive documents no
645
+ // account-identity endpoint to enrich them with — Klaviyo's connect
646
+ // reads the account name back; this has nothing cited to read. The
647
+ // callback stores the tokens and skips enrichment on `unimplemented`.
648
+ connect : {},
649
+ disconnect : {},
650
+ probe : false,
651
+ scopes : false,
652
+ // THE ONE THING WRAPPED, and it is about the response rather than the
653
+ // request. Attentive's token reply carries expires_in : 900 while their
654
+ // auth overview says access tokens "do not expire" — and no refresh
655
+ // token exists to renew with. Storing that expiry would have
656
+ // accessToken() declaring the credential spent fifteen minutes after
657
+ // consent, with nothing to renew it: every connection would demand
658
+ // reconnecting four times an hour.
659
+ //
660
+ // The overview's answer is modelled — the expiry is dropped, so the
661
+ // token is treated as long-lived. Registration item 1 in the header is
662
+ // the live exchange that proves this right or wrong.
663
+ token : async ( args ) => {
664
+
665
+ const minted = await authToken( args );
666
+
667
+ return { ...minted, expiresIn : null };
668
+
669
+ }
670
+ },
671
+ commerce : false,
672
+ contacts : { remove : false, sync : false },
673
+ email : false,
674
+ inbound : false,
675
+ lifecycle : false,
676
+ resources : {
677
+
678
+ // The segments a merchant can sync into, for the picker on their
679
+ // connection.
680
+ //
681
+ // GET /v2/segments (cited in the header). `limit` caps at 1000 in their
682
+ // own spec, defaulting to 20 — low enough that leaving it unset would
683
+ // show a picker quietly missing most of a real account. The response's
684
+ // only identifier is `externalId`, so an entry without one cannot be
685
+ // stored and is dropped.
686
+ audiences : async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
687
+
688
+ const query = new URLSearchParams({
689
+ limit : String( Math.min( limit, 1000 ) ),
690
+ ...( cursor && { cursor } ),
691
+ ...( search?.value && { name : String( search.value ).trim() } )
692
+ });
693
+
694
+ const response = await fetcher(
695
+ 'https://api.attentivemobile.com/v2/segments?' + query,
696
+ {
697
+ headers : { authorization : 'Bearer ' + token },
698
+ signal : AbortSignal.timeout( 15000 )
699
+ }
700
+ );
701
+
702
+ if( ! response.ok ){
703
+
704
+ throw Object.assign(
705
+ new Error( 'Attentive refused the request (' + response.status + ')' ),
706
+ { status : response.status }
707
+ );
708
+
709
+ }
710
+
711
+ const body = await response.json();
712
+
713
+ return {
714
+ items : ( body?.segments || [] )
715
+ .filter( ( segment ) => segment?.externalId )
716
+ .map( ( segment ) => ({ id : segment.externalId, title : segment?.name || segment.externalId }) ),
717
+ pageInfo : {
718
+ endCursor : body?.hasMore ? ( body?.cursor || null ) : null,
719
+ hasNextPage : Boolean( body?.hasMore )
720
+ }
721
+ };
722
+
723
+ },
724
+ prices : false,
725
+ products : false,
726
+ promotions : false
727
+
728
+ },
729
+ segment : false,
730
+ sms : false,
731
+ webhook : false
732
+
733
+ },
734
+ icon: icon$4,
735
+ requires : [
736
+ 'ATTENTIVE_OAUTH_CLIENT_ID',
737
+ 'ATTENTIVE_OAUTH_CLIENT_SECRET'
738
+ ],
739
+ slug : 'attentive',
740
+ // A consent with no segment chosen is authenticated and inert — the sync,
741
+ // when it ships, needs somewhere to put people.
742
+ status : ( data ) => ( data?.settings?.segment ? data.status : 'pending' ),
743
+ // No steps: subscriber sync has not shipped, so this vendor contributes
744
+ // nothing to a workflow yet. An empty steps object is the honest declaration.
745
+ steps : {},
746
+ tasks : ( data ) => [
747
+ ...( data?.settings?.segment
748
+ ? []
749
+ : [
750
+ {
751
+ message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
752
+ title : 'Choose a segment'
753
+ }
754
+ ]
755
+ ),
756
+ {
757
+ message : 'Contact syncing to Attentive segments has not shipped yet. Nothing is being sent to Attentive right now.',
758
+ title : 'Subscriber sync not available yet',
759
+ type : 'warning'
760
+ }
761
+ ],
762
+ title : 'Attentive'
763
+ };
764
+
765
+ // Drawbridge, exported from the brand kit and left as authored — the fills are the
327
766
  // vendor's own mark, not a recolour.
328
767
  //
329
768
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
330
769
  // lib/ directly. A bare .svg import would need a bundler loader and force the
331
770
  // tests onto dist/, which is a worse trade than one line of wrapper.
332
771
  var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
772
+ <rect width="500" height="500" fill="#BAEC5F"/>
773
+ <g clip-path="url(#clip0_2115_2832)">
774
+ <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"/>
775
+ <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"/>
776
+ </g>
777
+ <defs>
778
+ <clipPath id="clip0_2115_2832">
779
+ <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
780
+ </clipPath>
781
+ </defs>
782
+ </svg>`;
783
+
784
+ // Drawbridge itself — the PRIVATE connection.
785
+ //
786
+ // Nobody connects this. There is no credential, no consent, and no card on the
787
+ // connections page. It exists because a workflow step points at a hook, and the
788
+ // six most common steps — sending an email, alerting a member, syncing a segment
789
+ // — are things Drawbridge does rather than things a vendor does.
790
+ //
791
+ // Before this, those steps were the exception: every other step resolved through
792
+ // a manifest and these resolved through a hardcoded registry in drawbridge-sync.
793
+ // One exception is all it takes for "every step is a declaration pointing at a
794
+ // hook" to stop being a rule somebody can rely on.
795
+ //
796
+ // PRIVATE is a real distinction, not a flag to hide a card. A public connection
797
+ // answers "can this merchant connect it" through `requires` and a plan feature.
798
+ // A private one is always present, in every deployment, for every organization —
799
+ // so it declares neither, and build() knows not to ask.
800
+ var drawbridge = {
801
+ auth : {
802
+ type : 'none'
803
+ },
804
+ content : {
805
+ confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
806
+ description : [
807
+ '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.'
808
+ ],
809
+ excerpt : 'The steps Drawbridge runs itself.',
810
+ guide : [
811
+ 'Nothing to do. These steps are available in every workflow builder.'
812
+ ]
813
+ },
814
+ exclusive : false,
815
+ fields : [],
816
+ group : 'developer',
817
+ // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
818
+ // suppression collection and the queues; segment sync needs the streams. A
819
+ // published package carrying those makes every consumer carry them, which is
820
+ // the reason `{}` exists as an answer.
821
+ hooks : {
822
+ auth : {
823
+ // Nothing to connect, revoke, probe or re-scope.
824
+ connect : false,
825
+ disconnect : false,
826
+ probe : false,
827
+ scopes : false,
828
+ token : false
829
+ },
830
+ commerce : false,
831
+ contacts : { remove : false, sync : false },
832
+ email : {
833
+ digest : {},
834
+ // To organization members. NEVER suppressed and never billed: an
835
+ // entrant's opt-out must not silence an alert to staff, and staff mail is
836
+ // not a metered send.
837
+ notify : {},
838
+ // To a lead. Suppression applies and the send is billed.
839
+ send : {}
840
+ },
841
+ inbound : false,
842
+ lifecycle : false,
843
+ resources : {
844
+ audiences : false,
845
+ prices : false,
846
+ products : false,
847
+ promotions : false
848
+ },
849
+ segment : { sync : {} },
850
+ sms : { send : {} },
851
+ webhook : false
852
+ },
853
+ icon: icon$3,
854
+ // PRIVATE: never in the catalog, always available to the builder.
855
+ private : true,
856
+ requires : [],
857
+ slug : 'drawbridge',
858
+ // Always on. There is no credential that could go bad and no configuration a
859
+ // merchant could leave half-finished.
860
+ status : () => 'active',
861
+ // DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
862
+ // below — trigger, billable, settings — is what that catalog and the workflow
863
+ // route already enforce today, because this replaces them rather than
864
+ // competing with them.
865
+ //
866
+ // NOT HERE, deliberately:
867
+ //
868
+ // step.segment.sync a SYSTEM step, dispatched by drawbridge-sync rather
869
+ // than offered in the builder. It fans out, so the shell
870
+ // opens its step document and the chunks close it.
871
+ steps : {
872
+
873
+ email : {
874
+
875
+ // SCHEDULE-TRIGGERED, not lead-triggered: it is offered under Daily,
876
+ // Weekly and Monthly. Those triggers had offered no steps at all, so a
877
+ // scheduled workflow was selectable and inert until this landed.
878
+ digest : () => ({
879
+ hook : 'email.digest',
880
+ key : 'Email — Digest',
881
+ queue : 'notification',
882
+ settings : {
883
+ // The organization OWNER is always a recipient, resolved in sync,
884
+ // so this is additional recipients rather than the list. It cannot
885
+ // be required: the members endpoint is owner-gated and the owner is
886
+ // not a member document, so a solo merchant has nothing to pick and
887
+ // could never save the step.
888
+ members : { of : 'string', type : 'array' },
889
+ message : { required : true, type : 'string' },
890
+ subject : { required : true, type : 'string' }
891
+ },
892
+ triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
893
+ usage : { actions : 0 }
894
+ }),
895
+
896
+ // To organization MEMBERS. Never suppressed — an entrant's opt-out must
897
+ // not silence an alert to staff — and not billed.
898
+ notify : () => ({
899
+ hook : 'email.notify',
900
+ key : 'Email — Notification',
901
+ queue : 'notification',
902
+ settings : {
903
+ members : { of : 'string', type : 'array' },
904
+ message : { required : true, type : 'string' },
905
+ subject : { required : true, type : 'string' }
906
+ },
907
+ triggers : [ 'lead.insert' ],
908
+ // Zero is a PRICE, and a deliberate one. Declared rather than omitted
909
+ // so "this is free" and "nobody decided" stay different statements;
910
+ // completeStep bills only when actions > 0.
911
+ usage : { actions : 0 }
912
+ }),
913
+
914
+ // To a LEAD. Suppression applies and the send is billed.
915
+ send : () => ({
916
+ hook : 'email.send',
917
+ key : 'Email — Send email',
918
+ queue : 'notification',
919
+ settings : {
920
+ message : { required : true, type : 'string' },
921
+ subject : { required : true, type : 'string' }
922
+ },
923
+ triggers : [ 'lead.insert' ],
924
+ // ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
925
+ // customer-facing number; the handler read it too, so the same fact
926
+ // was stated in two places and only one of them was reviewed.
927
+ usage : { actions : channels.email.actionsPerSend }
928
+ })
929
+
930
+ },
931
+
932
+ // WITHDRAWN, which is a third thing from builder and system: declared,
933
+ // routed and runnable, but never offered.
934
+ //
935
+ // It went when the twilio connection did — a connection-gated step with no
936
+ // connection to gate on could only ever render permanently disabled. Stored
937
+ // workflows still carry it, so it must keep running, and enums.step.type
938
+ // keeps it for the same reason.
939
+ //
940
+ // NO TRIGGERS is what keeps it out of the builder: the catalog derives from
941
+ // triggers, so a step with none is unreachable by a merchant without a
942
+ // second list saying so.
943
+ //
944
+ // Platform SMS returns as a base step the way email did. That is this entry
945
+ // gaining triggers, not a new one.
946
+ sms : {
947
+
948
+ send : () => ({
949
+ hook : 'sms.send',
950
+ key : 'Send an SMS',
951
+ queue : 'notification',
952
+ settings : {
953
+ message : { required : true, type : 'string' },
954
+ subject : { required : true, type : 'string' }
955
+ },
956
+ // Priced per SEGMENT and billed from the first, which the send
957
+ // resolves from the message length. This is the floor.
958
+ usage : { actions : channels.sms.actionsPerSegment },
959
+ withdrawn : true
960
+ })
961
+
962
+ },
963
+
964
+ segment : {
965
+
966
+ // FANS OUT. It evaluates every contact in the organization against every
967
+ // segment, which is too much for one job — so the hook returns chunks and
968
+ // the shell defers completion: openStep writes the document with a slot
969
+ // per chunk, and whichever chunk lands last closes it and resumes the
970
+ // chain.
971
+ //
972
+ // It carries a hook like every other step. An earlier version declared
973
+ // none, on the theory that fan-out was a second protocol the shell could
974
+ // not run; it is the same protocol with the ending deferred, and a step
975
+ // declaring no hook is silently SKIPPED by the runner.
976
+ sync : () => ({
977
+ description : 'Recalculates segment membership on a daily schedule.',
978
+ hook : 'segment.sync',
979
+ key : 'Segment Sync',
980
+ queue : 'segment',
981
+ system : true
982
+ })
983
+
984
+ }
985
+
986
+ },
987
+ tasks : () => [],
988
+ title : 'Drawbridge'
989
+ };
990
+
991
+ // HubSpot — PRIVATE, and Drawbridge's own CRM rather than a merchant's.
992
+ //
993
+ // A signup's UTM properties go onto a contact in OUR portal, keyed by the
994
+ // `hubspotId` cached on the `user` document. There is no merchant credential and
995
+ // no card: one Private App token, one portal, ours.
996
+ //
997
+ // PRIVATE RATHER THAN ABSENT, so the last vendor transport outside a manifest
998
+ // comes inside one. Its hooks are called by drawbridge-sync's user stream rather
999
+ // than by a workflow step — which is not unusual: auth.* hooks are called by the
1000
+ // OAuth runner and inbound.* by the webhooks route. A hook is a verb, and only
1001
+ // some verbs are steps.
1002
+ //
1003
+ // FLIPPING IT PUBLIC LATER is a real possibility — merchants syncing their own
1004
+ // entrants to their own portal is the Klaviyo shape exactly. When that happens it
1005
+ // is a SECOND manifest, not this one edited: this writes Drawbridge account
1006
+ // holders into our sales pipeline, and a merchant's entrants must never land
1007
+ // there. `token` is already a parameter so the two can share this transport
1008
+ // without ever sharing a credential.
1009
+
1010
+ const HUBSPOT_BASE = 'https://api.hubapi.com';
1011
+
1012
+ // Best-effort: with no Private App token configured the whole integration
1013
+ // no-ops so a missing secret never crashes sync. Callers wrap these in
1014
+ // try/catch and treat a missing id gracefully.
1015
+ const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
1016
+
1017
+ return ( fetcher || request )({
1018
+ body,
1019
+ headers : {
1020
+ 'Authorization' : 'Bearer ' + ( token || process.env.HUBSPOT_ACCESS_TOKEN )
1021
+ },
1022
+ method,
1023
+ query,
1024
+ url : HUBSPOT_BASE + path
1025
+ });
1026
+
1027
+ };
1028
+
1029
+ // Custom contact properties carrying the campaign a signup arrived on
1030
+ // (`user.utm`, written by provisionUser in the api's route/oauth.js).
1031
+ //
1032
+ // CUSTOM BECAUSE HUBSPOT HAS NO EQUIVALENT — verified against the portal's own
1033
+ // property list, where nothing is named or labelled utm. See the note on
1034
+ // CLICK_PROPERTIES below for what the analytics family can and cannot do.
1035
+ //
1036
+ // THESE MUST EXIST IN THE PORTAL — Settings -> Properties -> Contacts, single-line
1037
+ // text — or HubSpot rejects the whole payload. See the retry in send() below, which
1038
+ // is what stops a missing property from costing a new user its contact.
1039
+ //
1040
+ // NAMED FOR THE PARAMETER, not for us. These are the six names Google's Campaign URL
1041
+ // Builder emits, so the property is called exactly what the link that produced it
1042
+ // carried — utm_source holds what arrived as ?utm_source=. A drawbridge_ prefix would
1043
+ // have made them ours to explain; this way anything that already speaks the standard,
1044
+ // including a future import or a tool somebody adds to the portal, maps onto them with
1045
+ // nothing to translate.
1046
+ //
1047
+ // The underscores are the convention's, not a slip: these are external property names
1048
+ // matching a url parameter, which is the one place that spelling is correct.
1049
+ //
1050
+ // `id` matters most: drawbridge-growth stamps utm_id with the campaign slug, so it is
1051
+ // what resolves a contact back to one campaign document. The rest describe it.
1052
+ const UTM_PROPERTIES = {
1053
+ campaign : 'utm_campaign',
1054
+ content : 'utm_content',
1055
+ id : 'utm_id',
1056
+ medium : 'utm_medium',
1057
+ source : 'utm_source',
1058
+ term : 'utm_term'
1059
+ };
1060
+
1061
+ // Click ids go into HubSpot's OWN properties, which are writable and already exist in
1062
+ // every portal — checked against ours, where modificationMetadata.readOnlyValue is
1063
+ // false on all five. A custom property here would have been a worse copy of a field
1064
+ // HubSpot's ads tooling already understands, and one more thing somebody has to create
1065
+ // by hand.
1066
+ //
1067
+ // There is no equivalent for the utm tags. Nothing in the contact schema is named or
1068
+ // labelled utm; the nearest family is hs_analytics_*, and it cannot hold these:
1069
+ // hs_analytics_source is a coarse enumeration (PAID_SEARCH, SOCIAL_MEDIA...) with no
1070
+ // room for a campaign id, and the drill-downs that DO carry campaign detail --
1071
+ // hs_analytics_source_data_1 and _2 -- are read-only, written only by HubSpot's own
1072
+ // tracking script. Hence the custom properties above, and only above.
1073
+ const CLICK_PROPERTIES = {
1074
+ fbclid : 'hs_facebook_click_id',
1075
+ gclid : 'hs_google_click_id',
1076
+ liFatId : 'hs_linkedin_click_id',
1077
+ msclkid : 'hs_bing_click_id',
1078
+ ttclid : 'hs_tiktok_click_id'
1079
+ };
1080
+
1081
+ // Which properties may not exist in the portal, and so are the ones worth dropping on
1082
+ // a 400. The native click ids are NOT in this set: they ship with every portal, so a
1083
+ // 400 mentioning one is a real error rather than a setup gap, and silently dropping it
1084
+ // would hide that.
1085
+ //
1086
+ // Derived from the map above rather than matched on a `utm_` prefix, so the two cannot
1087
+ // disagree — and so a property added here later is not silently made droppable by the
1088
+ // shape of its name.
1089
+ const DROPPABLE = new Set( Object.values( UTM_PROPERTIES ) );
1090
+
1091
+ const isUtmProperty = ( key ) => DROPPABLE.has( key );
1092
+
1093
+ // HubSpot contact properties are lowercase (email/firstname/lastname). Only
1094
+ // send the keys we actually have so a name-less user doesn't clear fields —
1095
+ // the same rule applies to the campaign, which most contacts will not have.
1096
+ const toProperties = ({ email, firstName, lastName, utm }) => {
1097
+
1098
+ const properties = {};
1099
+
1100
+ if( email !== undefined ) properties.email = email;
1101
+ if( firstName !== undefined ) properties.firstname = firstName;
1102
+ if( lastName !== undefined ) properties.lastname = lastName;
1103
+
1104
+ if( utm ){
1105
+
1106
+ for( const [ key, property ] of Object.entries( UTM_PROPERTIES ) ){
1107
+
1108
+ if( utm[ key ] ) properties[ property ] = utm[ key ];
1109
+
1110
+ }
1111
+ // Each click id into the property HubSpot already keeps for that platform.
1112
+ // Only one is ever set in practice — whichever platform the click came from —
1113
+ // so the other four simply never appear.
1114
+ for( const [ key, property ] of Object.entries( CLICK_PROPERTIES ) ){
1115
+
1116
+ if( utm.click?.[ key ] ) properties[ property ] = utm.click[ key ];
1117
+
1118
+ }
1119
+ }
1120
+ return properties;
1121
+
1122
+ };
1123
+
1124
+ // One request, with a retry that drops the campaign properties.
1125
+ //
1126
+ // HubSpot 400s the ENTIRE request if a single property does not exist in the portal,
1127
+ // and stream/user.js swallows the error — so without this fallback a portal missing
1128
+ // the custom properties would silently cost every new user its hubspotId, breaking
1129
+ // the CRM link for a reason nobody would think to look for. Losing the campaign is
1130
+ // acceptable; losing the contact is not.
1131
+ //
1132
+ // Worth having even with the properties in place: somebody renaming or archiving one
1133
+ // in the portal is a change this repo cannot see coming.
1134
+ const send = async ({ doc, fetcher, method, path, token }) => {
1135
+
1136
+ const properties = toProperties( doc );
1137
+
1138
+ try {
1139
+
1140
+ return await hubspotRequest({
1141
+ body : { properties },
1142
+ fetcher,
1143
+ method,
1144
+ path,
1145
+ token
1146
+ });
1147
+
1148
+ } catch ( error ) {
1149
+
1150
+ const enriched = Object.keys( properties ).some( isUtmProperty );
1151
+
1152
+ if( error?.status !== 400 || ! enriched ) throw error;
1153
+
1154
+ return await hubspotRequest({
1155
+ body : {
1156
+ properties : Object.fromEntries(
1157
+ Object.entries( properties ).filter( ( [ key ] ) => ! isUtmProperty( key ) )
1158
+ )
1159
+ },
1160
+ fetcher,
1161
+ method,
1162
+ path,
1163
+ token
1164
+ });
1165
+
1166
+ }
1167
+
1168
+ };
1169
+
1170
+ // Find a contact id by email. Swallows its own errors: a search that fails must
1171
+ // not stop the create that would follow it.
1172
+ const lookup = async ({ email, fetcher, token }) => {
1173
+
1174
+ if( ! token || ! email ) return;
1175
+
1176
+ try {
1177
+
1178
+ const body = await hubspotRequest({
1179
+ body : {
1180
+ filterGroups : [
1181
+ {
1182
+ filters : [
1183
+ {
1184
+ operator : 'EQ',
1185
+ propertyName : 'email',
1186
+ value : email
1187
+ }
1188
+ ]
1189
+ }
1190
+ ],
1191
+ limit : 1,
1192
+ properties : [ 'email' ]
1193
+ },
1194
+ fetcher,
1195
+ method : 'POST',
1196
+ path : '/crm/v3/objects/contacts/search',
1197
+ token
1198
+ });
1199
+
1200
+ return body?.results?.[ 0 ]?.id;
1201
+
1202
+ } catch ( error ){
1203
+
1204
+ // Deliberately swallowed.
1205
+
1206
+ }
1207
+
1208
+ };
1209
+
1210
+ var hubspot = {
1211
+ auth : {
1212
+ // A Private App token from our own portal. Nothing to connect, nothing to
1213
+ // consent to, and no merchant involved.
1214
+ type : 'none'
1215
+ },
1216
+ content : {
1217
+ confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
1218
+ description : [
1219
+ 'Drawbridge keeps its own HubSpot portal in step with account signups, so the campaign a customer arrived on is on their contact record.'
1220
+ ],
1221
+ excerpt : 'Drawbridge\'s own CRM sync.',
1222
+ guide : [
1223
+ 'Nothing to do. This is internal to Drawbridge.'
1224
+ ]
1225
+ },
1226
+ exclusive : false,
1227
+ fields : [],
1228
+ group : 'contacts',
1229
+ hooks : {
1230
+ auth : {
1231
+ connect : false,
1232
+ disconnect : false,
1233
+ probe : false,
1234
+ scopes : false,
1235
+ token : false
1236
+ },
1237
+ commerce : false,
1238
+ contacts : {
1239
+
1240
+ // FORGET A CONTACT, by id or by email. Account deletion — the caller had
1241
+ // to search then remove, which is one round trip it should not have to
1242
+ // know about.
1243
+ remove : async ({ email, fetcher, id, token }) => {
1244
+
1245
+ const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1246
+
1247
+ if( ! key ) return;
1248
+
1249
+ const contact = id || await lookup({ email, fetcher, token : key });
1250
+
1251
+ if( ! contact ) return;
1252
+
1253
+ return hubspotRequest({
1254
+ fetcher,
1255
+ method : 'DELETE',
1256
+ path : '/crm/v3/objects/contacts/' + contact,
1257
+ token : key
1258
+ });
1259
+
1260
+ },
1261
+
1262
+ // Connect an account to its contact by email, creating it if absent, and
1263
+ // return the contact id. Unlike SendGrid, HubSpot renames a contact's
1264
+ // email in place, so an email change is a plain PATCH on the cached id —
1265
+ // no delete-old-then-create-new.
1266
+ //
1267
+ // Prefer the cached hubspotId; fall back to a search; create last.
1268
+ sync : async ({ doc, fetcher, token }) => {
1269
+
1270
+ const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1271
+
1272
+ // NO TOKEN IS A NO-OP, not an error. A deployment without a portal
1273
+ // configured must not crash the user stream.
1274
+ if( ! key ) return;
1275
+
1276
+ if( doc?.hubspotId ){
1277
+
1278
+ try {
1279
+
1280
+ return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token : key }) )?.id;
1281
+
1282
+ } catch ( error ){
1283
+
1284
+ // The cached contact was deleted in HubSpot (404) or is otherwise
1285
+ // unusable — fall through to search and create.
1286
+ if( error?.status !== 404 ) throw error;
1287
+
1288
+ }
1289
+
1290
+ }
1291
+
1292
+ const existing = await lookup({ email : doc?.email, fetcher, token : key });
1293
+
1294
+ return ( await send({
1295
+ doc,
1296
+ fetcher,
1297
+ method : existing ? 'PATCH' : 'POST',
1298
+ path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
1299
+ token : key
1300
+ }) )?.id;
1301
+
1302
+ }
1303
+
1304
+ },
1305
+ email : false,
1306
+ inbound : false,
1307
+ lifecycle : false,
1308
+ resources : {
1309
+ audiences : false,
1310
+ prices : false,
1311
+ products : false,
1312
+ promotions : false
1313
+ },
1314
+ segment : false,
1315
+ sms : false,
1316
+ webhook : false
1317
+ },
1318
+ icon: icon$3,
1319
+ // Borrowed: the Drawbridge mark, because this is ours and never rendered.
1320
+ private : true,
1321
+ // Absent the token the hooks no-op, so a deployment without a portal simply
1322
+ // contributes nothing rather than failing.
1323
+ requires : [ 'HUBSPOT_ACCESS_TOKEN' ],
1324
+ slug : 'hubspot',
1325
+ status : () => 'active',
1326
+ // No workflow steps. The hooks are called by the user stream, not the builder.
1327
+ steps : {},
1328
+ tasks : () => [],
1329
+ title : 'HubSpot'
1330
+ };
1331
+
1332
+ // Klaviyo, exported from the brand kit and left as authored — the fills are the
1333
+ // vendor's own mark, not a recolour.
1334
+ //
1335
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
1336
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
1337
+ // tests onto dist/, which is a worse trade than one line of wrapper.
1338
+ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
333
1339
  <rect width="500" height="500" fill="white"/>
334
1340
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
335
1341
  </svg>`;
336
1342
 
337
- // Klaviyo pins its API by DATE. A request without this header is refused, and
338
- // one with an old date keeps the response shape that date shipped with — which
339
- // is the point: bumping it is a deliberate act with a changelog to read, not
340
- // something that drifts under us.
341
- const REVISION = '2026-07-15';
342
-
343
- const api = async ( path, { fetcher = fetch, token } ) => {
1343
+ const api = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
344
1344
 
345
1345
  const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
1346
+ ...( payload && { body : JSON.stringify( payload ) }),
346
1347
  headers : {
347
1348
  // Bearer, not Klaviyo-API-Key — that header is for private keys, and
348
1349
  // sending it with an OAuth token fails in a way that reads like a bad
349
1350
  // token rather than a bad scheme.
350
1351
  authorization : 'Bearer ' + token,
351
- revision : REVISION
1352
+ ...( payload && { 'content-type' : 'application/json' }),
1353
+ // Klaviyo pins its API by DATE. A request without this header is
1354
+ // refused, and one with an old date keeps the response shape that date
1355
+ // shipped with — which is the point: bumping it is a deliberate act
1356
+ // with a changelog to read, not something that drifts under us.
1357
+ revision : '2026-07-15'
352
1358
  },
1359
+ method,
353
1360
  signal : AbortSignal.timeout( 15000 )
354
1361
  });
355
1362
 
@@ -362,294 +1369,553 @@ const api = async ( path, { fetcher = fetch, token } ) => {
362
1369
 
363
1370
  }
364
1371
 
365
- return response.json();
1372
+ // 204 on a subscription write — nothing to parse, and asking for json throws.
1373
+ return response.status === 204 ? null : response.json();
366
1374
 
367
1375
  };
368
1376
 
369
- // Klaviyo — contact and event sync, over OAuth.
1377
+ // Klaviyo — contact sync, over OAuth.
1378
+ //
1379
+ // EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
1380
+ // how it authenticates, what it can be asked for, and what its workflow step
1381
+ // does. Nothing about Klaviyo lives in the api, in drawbridge-sync, or in a
1382
+ // form component.
1383
+ //
1384
+ // TWO MAPS, and they are different things rather than one thing split in half:
370
1385
  //
371
- // THE FIRST VENDOR ADDED AS A MANIFEST AND NOTHING ELSE. No form component, no
372
- // connect route, no consent builder, no token exchange: the shared runner in
373
- // oauth.js reads the descriptor below and does all of it. That is the test of
374
- // whether this exercise worked, and the things it forced out are the interesting
375
- // part — clientAuth and pkce both exist because Klaviyo needs them and Google
376
- // does not.
1386
+ // hooks protocol operations. Called synchronously, return data, never
1387
+ // user-facing. Grouped by domain, mirroring the vocabulary.
1388
+ // steps workflow units. Queued, billable, offered in the builder by
1389
+ // trigger, and their type is stored in documents under a Mongo enum.
1390
+ //
1391
+ // A HOOK'S VALUE IS ITS ANSWER, which is why no separate `supports` map exists
1392
+ // to drift from this one:
1393
+ //
1394
+ // false declined — a decision recorded, not silence
1395
+ // function supported, and the body is here
1396
+ // {} supported, implemented in the repo that has the dependencies
377
1397
  var klaviyo = {
378
1398
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
379
1399
  // exchange without a code_verifier matching the challenge the consent
380
1400
  // carried. Most vendors treat it as optional hardening; this one does not,
381
1401
  // which is why it is a descriptor flag and not a global.
382
1402
  //
383
- // clientAuth is the other thing Klaviyo does differently. The token endpoint
384
- // wants HTTP Basic base64( client_id : client_secret ) in an Authorization
385
- // header — and rejects the same pair sent as form fields, which is how every
386
- // Google product wants it.
1403
+ // HTTP Basic on the token endpoint is the other thing Klaviyo does
1404
+ // differently, and it says so in hooks.auth.token rather than as a flag here.
387
1405
  auth : {
388
1406
  oauth : {
389
- // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
390
- //
391
- // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
392
- // a.klaviyo.com -- their API host -- sends the merchant somewhere that
393
- // never renders a consent screen, so the journey stalls with no error
394
- // anybody can see.
395
- //
396
- // token is a server call and must stay on a.klaviyo.com: Klaviyo began
397
- // blocking OAuth token traffic through www on 2025-03-31, so the mirror
398
- // image of this mistake breaks the exchange instead of the consent.
399
- authorize : 'https://www.klaviyo.com/oauth/authorize',
400
1407
  // NAMES the env vars holding OUR application's client. One identity,
401
1408
  // every merchant — the token is the merchant's and arrives from their
402
1409
  // own consent, which is what stops one organization reading another's
403
1410
  // data.
1411
+ //
404
1412
  client : {
405
1413
  id : 'KLAVIYO_OAUTH_CLIENT_ID',
406
1414
  secret : 'KLAVIYO_OAUTH_CLIENT_SECRET'
407
1415
  },
408
- clientAuth : 'basic',
409
- pkce : true,
410
- // DECLARED, never derived from the slug. It is registered in Klaviyo's
411
- // app settings and they refuse anything that does not byte-match, so
412
- // it is a fact about someone else's records rather than a string this
413
- // code computes. Deriving one from a provider key produced
414
- // redirect_uri_mismatch on a connection nobody had touched.
415
1416
  // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
416
1417
  // never mentions this at runtime — you discover it when a refresh fails
417
1418
  // on a connection nobody touched — so it is declared, and it is why
418
1419
  // auth.probe has to run on a schedule rather than only before a call.
419
- idleExpiry : 90 * 24 * 60 * 60,
420
- redirect : '/api/connection/klaviyo/callback',
1420
+ //
1421
+ // Token lifetime is NOT declared: the vendor states it on every
1422
+ // exchange, and a copy here would be a second answer that goes stale.
1423
+ expiry : 90 * 24 * 60 * 60,
1424
+ pkce : true,
421
1425
  // Space separated. accounts:read is required by Klaviyo on every app
422
1426
  // and must stay in the list; the rest are what a contact sync needs.
423
1427
  scopes : 'accounts:read lists:read lists:write profiles:read profiles:write',
424
- token : 'https://a.klaviyo.com/oauth/token'
1428
+ // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
1429
+ // the disconnect hook — three vendor addresses, two of them declared,
1430
+ // which is exactly the kind of split that goes unnoticed.
1431
+ urls : {
1432
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite ways.
1433
+ //
1434
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it
1435
+ // at a.klaviyo.com — their API host — sends the merchant somewhere
1436
+ // that never renders a consent screen, so the journey stalls with no
1437
+ // error anybody can see.
1438
+ //
1439
+ // token and revoke are server calls and must stay on a.klaviyo.com:
1440
+ // Klaviyo began blocking OAuth token traffic through www on
1441
+ // 2025-03-31, so the mirror image of this mistake breaks the exchange
1442
+ // instead of the consent.
1443
+ authorize : 'https://www.klaviyo.com/oauth/authorize',
1444
+ // WHERE THE MERCHANT LANDS — the dashboard, not drawbridge-api. The
1445
+ // `/api/` segment is Next's route-handler convention, which reads as
1446
+ // the api service to everyone who sees it; it is not, and the route
1447
+ // has never moved. build() pins it against the one callback route
1448
+ // that exists, because declared-but-wrong fails AFTER consent — a
1449
+ // 404 for someone who has already granted access.
1450
+ //
1451
+ // Registered in Klaviyo's own app settings, and they refuse anything
1452
+ // that does not byte-match, so it is a fact about someone else's
1453
+ // records rather than a string this code computes.
1454
+ redirect : '/api/connection/klaviyo/callback',
1455
+ revoke : 'https://a.klaviyo.com/oauth/revoke',
1456
+ token : 'https://a.klaviyo.com/oauth/token'
1457
+ }
425
1458
  },
426
1459
  type : 'oauth'
427
1460
  },
428
- category : 'contacts',
429
- confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
430
- connect : {
1461
+ // EVERYTHING A MERCHANT READS. Grouped by who it is for rather than by what
1462
+ // kind of sentence it is, so the question on the next vendor is "does a person
1463
+ // read this", which nobody gets wrong, instead of "is this marketing", which
1464
+ // someone will.
1465
+ //
1466
+ // `errors` is in here rather than at the top level, and that is not a
1467
+ // preference. The connection DOCUMENT carries its own `errors` array of
1468
+ // scope-drift entries, and the document is spread OVER the resolved manifest
1469
+ // downstream — so a top-level `errors` here would be silently replaced by that
1470
+ // array and this copy would never render. `fields`/`settings` already carry a
1471
+ // comment about the same collision.
1472
+ content : {
1473
+
1474
+ // Shown at disconnect, so it says what is lost and what is not.
1475
+ confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
1476
+
1477
+ description : [
1478
+ '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.',
1479
+ '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.',
1480
+ '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.'
1481
+ ],
1482
+
1483
+ // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
1484
+ // likely to grow — resources.* has already earned somewhere to put "we
1485
+ // could not load your lists" — so a new area adds a key here rather than a
1486
+ // second errors object somewhere else.
1487
+ //
1488
+ // `connect` no longer exists as a container above: its only other member
1489
+ // was `redirect`, which moved to auth.oauth.urls with the rest of the
1490
+ // vendor's addresses.
431
1491
  errors : {
432
- denied : 'The Klaviyo authorization was declined, so nothing was connected.',
433
- invalid : 'We couldn\'t complete the Klaviyo connection. Try connecting again.'
434
- }
1492
+ connect : {
1493
+ denied : 'The Klaviyo authorization was declined, so nothing was connected.',
1494
+ invalid : 'We couldn\'t complete the Klaviyo connection. Try connecting again.'
1495
+ }
1496
+ },
1497
+
1498
+ excerpt : 'Sync the contacts your campaigns collect into a Klaviyo list.',
1499
+
1500
+ // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
1501
+ // rendered — four useful instructions no component displayed.
1502
+ guide : [
1503
+ 'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
1504
+ 'Sign in to Klaviyo if you are not already, and choose the account to connect.',
1505
+ 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
1506
+ 'You can revoke access at any time from Klaviyo, under Integrations.'
1507
+ ]
1508
+
435
1509
  },
436
- description : [
437
- '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.',
438
- '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.',
439
- '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.'
440
- ],
441
- excerpt : 'Sync the contacts your campaigns collect into a Klaviyo list.',
1510
+ // CAN A MERCHANT KEEP TWO OF THESE AT ONCE?
1511
+ //
1512
+ // Required, and false is a decision rather than a default. Shopify is
1513
+ // exclusive because a store maps to exactly one organization. Contact syncs
1514
+ // are destinations — someone can reasonably keep Klaviyo and Mailchimp both
1515
+ // current so the exclusivity that once applied when these were SENDERS is
1516
+ // deliberately gone. That was removed once already; declaring it out loud is
1517
+ // what stops it coming back by inference.
1518
+ exclusive : false,
442
1519
  feature : 'organization:connection:klaviyo',
443
- // Nothing typed. The consent returns the grant, and the account it belongs to
444
- // is read back from Klaviyo rather than asked for.
445
1520
  fields : [
446
1521
  {
447
1522
  key : 'account',
448
1523
  label : 'Klaviyo account'
449
1524
  },
450
1525
  {
1526
+ // The choices come from the merchant's own account, so the field names
1527
+ // the capability and the client composes the url.
1528
+ hook : 'resources.audiences',
451
1529
  input : 'select',
452
1530
  key : 'list',
453
1531
  label : 'Klaviyo list',
454
1532
  message : 'Contacts your campaigns collect are synced into this list.',
455
1533
  required : true,
456
- // The choices come from the merchant's own account, not from here see
457
- // catalog.audiences below. Static options would mean asking somebody to
458
- // paste a list id copied out of another browser tab.
459
- source : 'catalog.audiences'
1534
+ // Klaviyo's list endpoint carries no name filter, so the hook can only
1535
+ // match what it already fetched. A search box that searches one page is
1536
+ // worse than none, so the picker does not offer one.
1537
+ search : false
460
1538
  }
461
1539
  ],
462
- // The three auth hooks, all pure HTTP against Klaviyowhich is why they
463
- // live here rather than in sync. A vendor's own protocol belongs beside the
464
- // vendor.
1540
+ // WHAT KIND OF THING THIS IS. One field, not two `category` said the same
1541
+ // thing and was read by nothing, while `group` was quietly doing double duty
1542
+ // as the mutual-exclusion key. The exclusion moved to `exclusive` above, so
1543
+ // this is purely how a connection is grouped and labelled.
1544
+ group : 'contacts',
1545
+
1546
+ // Nothing typed at connect. The consent returns the grant, and the account it
1547
+ // belongs to is read back from Klaviyo rather than asked for.
465
1548
  hooks : {
466
- // Turn a fresh grant into settings worth showing. Without this the card
467
- // renders an empty "Klaviyo account" field, because the merchant is never
468
- // asked which account they connected — the consent already decided it and
469
- // asking again would be a question we can answer ourselves.
470
- 'auth.connect' : async ({ fetcher, tokens }) => {
471
1549
 
472
- const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
1550
+ auth : {
473
1551
 
474
- const account = body?.data?.[ 0 ];
1552
+ // Turn a fresh grant into settings worth showing. Without this the card
1553
+ // renders an empty "Klaviyo account" field, because the merchant is
1554
+ // never asked which account they connected — the consent already
1555
+ // decided it, and asking again would be a question we can answer.
1556
+ connect : async ({ fetcher, tokens }) => {
475
1557
 
476
- return {
477
- account : account?.attributes?.contact_information?.organization_name || account?.id || null,
478
- accountId : account?.id || null
479
- };
1558
+ const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
1559
+
1560
+ const account = body?.data?.[ 0 ];
1561
+
1562
+ return {
1563
+ account : account?.attributes?.contact_information?.organization_name || account?.id || null,
1564
+ accountId : account?.id || null
1565
+ };
1566
+
1567
+ },
1568
+
1569
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
1570
+ // grant live in the merchant's account, so a disconnect that looks
1571
+ // complete here still shows Drawbridge with access over there.
1572
+ //
1573
+ // Basic auth with our client, exactly like the token exchange — the
1574
+ // token being revoked is the subject, not the credential.
1575
+ disconnect : async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
1576
+
1577
+ const token = settings?.refreshToken || settings?.accessToken;
1578
+
1579
+ if( ! token ) return { revoked : false };
1580
+
1581
+ const response = await fetcher( manifest.auth.oauth.urls.revoke, {
1582
+ body : new URLSearchParams({
1583
+ token,
1584
+ token_type_hint : settings?.refreshToken ? 'refresh_token' : 'access_token'
1585
+ }),
1586
+ headers : {
1587
+ authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ),
1588
+ 'content-type' : 'application/x-www-form-urlencoded'
1589
+ },
1590
+ method : 'POST',
1591
+ signal : AbortSignal.timeout( 15000 )
1592
+ });
1593
+
1594
+ // A grant already revoked at the vendor answers non-2xx, and that is
1595
+ // the outcome we wanted — surfacing it as a failure would leave a
1596
+ // merchant unable to finish disconnecting.
1597
+ return { revoked : response.ok };
1598
+
1599
+ },
1600
+
1601
+ // THE MINT IS THE PROBE. Asking "is this token still good" by
1602
+ // inspecting what we stored answers the wrong question — a grant
1603
+ // revoked inside Klaviyo still looks perfect in our database. Spending
1604
+ // the refresh token is the only thing that asks Klaviyo.
1605
+ //
1606
+ // It also keeps the grant warm against the 90-day idle window above.
1607
+ probe : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
1608
+
1609
+ const token = await accessToken({
1610
+ clientId,
1611
+ clientSecret,
1612
+ fetcher,
1613
+ // Mint even if the stored token still looks good — a probe that
1614
+ // short-circuits never reaches Klaviyo and reports healthy on a
1615
+ // grant revoked an hour ago.
1616
+ force : true,
1617
+ manifest,
1618
+ settings
1619
+ });
1620
+
1621
+ return { ok : Boolean( token ) };
1622
+
1623
+ },
1624
+
1625
+ // Klaviyo scopes are fixed at app level and re-consented, not drifted.
1626
+ scopes : false,
1627
+
1628
+ // KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
1629
+ // client_id/client_secret pair as body fields. Everything else about the
1630
+ // request is standard, so this is the shared implementation told the one
1631
+ // thing that differs — in Klaviyo's own file, beside the rest of what
1632
+ // makes Klaviyo unusual, rather than as a flag a caller has to know to
1633
+ // read.
1634
+ token : ( args ) => authToken({ ...args, basic : true })
480
1635
 
481
1636
  },
482
- // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
483
- // grant live in the merchant's account, so a disconnect that looks
484
- // complete here still shows Drawbridge with access over there.
485
- //
486
- // Basic auth with our client, exactly like the token exchange — the token
487
- // being revoked is the subject, not the credential.
488
- // The lists a merchant can sync into, for the picker on their connection.
489
- //
490
- // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
491
- // so a single call quietly returns the first ten lists and an account with
492
- // more would show a picker missing the one they wanted — with nothing to
493
- // indicate anything was cut. Follows links.next, bounded so a runaway
494
- // cursor cannot spin forever.
495
- 'catalog.audiences' : async ({ cursor, fetcher, limit = 100, search, token }) => {
496
1637
 
497
- // Klaviyo pages by cursor and caps page[size] at 10, so `limit` is what
498
- // the CALLER wants back rather than what one request can carry — the
499
- // loop keeps pulling until it has that many or the vendor runs out.
500
- const audiences = [];
501
1638
 
502
- let next = cursor
503
- ? '/lists?page%5Bsize%5D=10&page%5Bcursor%5D=' + encodeURIComponent( cursor )
504
- : '/lists?page%5Bsize%5D=10';
1639
+ // No commerce here. Klaviyo tracks orders, but Drawbridge's order data comes
1640
+ // from the store that took the money — a second source for the same event
1641
+ // is two answers to "did this person buy", and the one we can bill from is
1642
+ // the store's.
1643
+ commerce : false,
1644
+
1645
+ // The verb the contacts.sync step points at. It does the work — including
1646
+ // writing the profile id back onto the lead — and returns what happened.
1647
+ contacts : {
1648
+
1649
+ // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1650
+ // different thing from deleting the profile.
1651
+ remove : false,
1652
+
1653
+ sync : async ({ contact, fetcher, lead, settings, suppressed, token }) => {
1654
+
1655
+ const list = settings?.list;
1656
+
1657
+ // incomplete() already stops a connection reaching Active without a
1658
+ // list; this is the belt to that braces. A workflow saved before the
1659
+ // list was chosen must not silently write into nothing.
1660
+ if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', skipped : true };
1661
+
1662
+ const email = lead?.canonical?.email?.value || lead?.email;
1663
+
1664
+ if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
1665
+
1666
+ // A Klaviyo profile is keyed on an email, so IDENTITY comes from the
1667
+ // lead. RANKING comes from the contact behind it, because only a
1668
+ // contact knows money: order attribution is person-level and merges
1669
+ // across every address a human used, so revenue and orders cannot
1670
+ // exist on a single-address record.
1671
+ const totals = contact?.totals || {};
1672
+
1673
+ const profile = await api( '/profiles/', {
1674
+ fetcher,
1675
+ method : 'POST',
1676
+ payload : {
1677
+ data : {
1678
+ attributes : {
1679
+ email,
1680
+ ...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
1681
+ properties : {
1682
+ drawbridge_campaigns : ( contact?.campaigns || [] ).length,
1683
+ drawbridge_draws : totals.draws || 0,
1684
+ drawbridge_entries : totals.entries || 0,
1685
+ drawbridge_orders : totals.orders || 0,
1686
+ // Campaign-attributed, NOT lifetime. A merchant running
1687
+ // Shopify already has lifetime revenue in Klaviyo through
1688
+ // Klaviyo's own integration; what only we can say is how
1689
+ // much a campaign drove. Named so the two cannot be
1690
+ // mistaken for one another in a segment builder.
1691
+ drawbridge_revenue : totals.gross || 0
1692
+ }
1693
+ },
1694
+ type : 'profile'
1695
+ }
1696
+ },
1697
+ token
1698
+ });
1699
+
1700
+ const profileId = profile?.data?.id;
1701
+
1702
+ if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
1703
+
1704
+ // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
1705
+ //
1706
+ // Omitting them means Klaviyo never learns they said no, so the
1707
+ // merchant can import them from somewhere else and start mailing them
1708
+ // again. Pushing them as unsubscribed makes the suppression travel
1709
+ // with the person, which is the reason this connection is allowed to
1710
+ // send anything at all.
1711
+ //
1712
+ // `suppressed` arrives as an argument because canSend() is sync's —
1713
+ // a manifest cannot reach it, and this rule is too important to infer.
1714
+ await api( '/profile-subscription-bulk-create-jobs/', {
1715
+ fetcher,
1716
+ method : 'POST',
1717
+ payload : {
1718
+ data : {
1719
+ attributes : {
1720
+ profiles : {
1721
+ data : [ {
1722
+ attributes : {
1723
+ email,
1724
+ subscriptions : {
1725
+ email : { marketing : { consent : suppressed ? 'UNSUBSCRIBED' : 'SUBSCRIBED' } }
1726
+ }
1727
+ },
1728
+ type : 'profile'
1729
+ } ]
1730
+ }
1731
+ },
1732
+ relationships : { list : { data : { id : list, type : 'list' } } },
1733
+ type : 'profile-subscription-bulk-create-job'
1734
+ }
1735
+ },
1736
+ token
1737
+ });
1738
+
1739
+ return {
1740
+ // Merged into `context` for later steps in this run.
1741
+ context : { klaviyoProfileId : profileId },
1742
+ message : suppressed
1743
+ ? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
1744
+ : 'Synced to the Klaviyo list.',
1745
+ // Recorded on the run for support to read back, not a write
1746
+ // instruction — the hook has already written what it needed to.
1747
+ response : { klaviyoProfileId : profileId }
1748
+ };
1749
+
1750
+ }
1751
+
1752
+ },
1753
+
1754
+ // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
1755
+ // register nothing with it, so listing four falses would be noise around a
1756
+ // single decision. Still explicit — absence would not say whether anybody
1757
+ // considered it.
1758
+ // Drawbridge sends its own notification email and SMS, and owns its own
1759
+ // segments — see the private `drawbridge` manifest. A vendor answering
1760
+ // these would be a second sender, which is the arrangement the platform
1761
+ // sender replaced.
1762
+ email : false,
1763
+ segment : false,
1764
+ sms : false,
1765
+ inbound : false,
1766
+
1767
+ // Nothing to set up or tear down at the vendor: the grant is the whole
1768
+ // integration, and revoking it is auth.disconnect's job.
1769
+ lifecycle : false,
1770
+
1771
+ resources : {
1772
+
1773
+ // The lists a merchant can sync into, for the picker on their
1774
+ // connection.
1775
+ //
1776
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to
1777
+ // it, so one call quietly returns the first ten lists and an account
1778
+ // with more shows a picker missing the one they wanted, with nothing to
1779
+ // indicate anything was cut.
1780
+ audiences : async ({ cursor, fetcher, limit = 100, search, token }) => {
1781
+
1782
+ // `limit` is what the CALLER wants back rather than what one request
1783
+ // can carry — the loop keeps pulling until it has that many or the
1784
+ // vendor runs out, and stops as soon as it does.
1785
+ const audiences = [];
1786
+
1787
+ let next = cursor
1788
+ ? '/lists?page%5Bsize%5D=10&page%5Bcursor%5D=' + encodeURIComponent( cursor )
1789
+ : '/lists?page%5Bsize%5D=10';
1790
+
1791
+ let pages = 0;
505
1792
 
506
- let pages = 0;
1793
+ while( next && audiences.length < limit && pages < 20 ){
507
1794
 
508
- while( next && audiences.length < limit && pages < 20 ){
1795
+ const body = await api( next, { fetcher, token });
509
1796
 
510
- const body = await api( next, { fetcher, token });
1797
+ for( const list of ( body?.data || [] ) ){
511
1798
 
512
- for( const list of ( body?.data || [] ) ){
1799
+ audiences.push({ id : list.id, title : list?.attributes?.name || list.id });
513
1800
 
514
- audiences.push({ id : list.id, title : list?.attributes?.name || list.id });
1801
+ }
1802
+
1803
+ const link = body?.links?.next;
1804
+
1805
+ // Klaviyo returns an absolute url; only the part after /api travels
1806
+ // on, because `api` prefixes its own base.
1807
+ next = link ? String( link ).replace( /^https:\/\/a\.klaviyo\.com\/api/, '' ) : null;
1808
+
1809
+ pages = pages + 1;
515
1810
 
516
1811
  }
517
1812
 
518
- const link = body?.links?.next;
1813
+ // Klaviyo's list endpoint has no name filter, so a search term is
1814
+ // applied to what came back. Honest about its own limits: with more
1815
+ // lists than `limit`, a term matching only later ones finds nothing.
1816
+ const term = String( search?.value || '' ).trim().toLowerCase();
1817
+
1818
+ return {
1819
+ items : term
1820
+ ? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
1821
+ : audiences,
1822
+ pageInfo : {
1823
+ endCursor : next,
1824
+ hasNextPage : Boolean( next )
1825
+ }
1826
+ };
519
1827
 
520
- // Klaviyo returns an absolute url; only the part after /api travels on,
521
- // because the shared caller prefixes its own base.
522
- next = link ? String( link ).replace( /^https:\/\/a\.klaviyo\.com\/api/, '' ) : null;
1828
+ },
523
1829
 
524
- pages = pages + 1;
1830
+ // Klaviyo sells no products and mints no discount codes.
1831
+ prices : false,
1832
+ products : false,
1833
+ promotions : false
525
1834
 
526
- }
1835
+ },
1836
+
1837
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
1838
+ webhook : false
1839
+
1840
+ },
1841
+ icon: icon$2,
1842
+ requires : [
1843
+ 'KLAVIYO_OAUTH_CLIENT_ID',
1844
+ 'KLAVIYO_OAUTH_CLIENT_SECRET'
1845
+ ],
1846
+ slug : 'klaviyo',
1847
+ // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
1848
+ // is already the merchant-facing copy channel and is already rendered.
1849
+ //
1850
+ // A grant with no list chosen is authenticated and useless. The list cannot be
1851
+ // part of the consent flow — enumerating lists needs the token the consent
1852
+ // returns — so it is always a second step, and the card must say Pending
1853
+ // rather than Active over nothing.
1854
+ //
1855
+ // Otherwise the credential's own verdict stands. A manifest can only ever
1856
+ // DOWNGRADE: it can see the settings, and it cannot see whether the grant was
1857
+ // revoked at Klaviyo an hour ago.
1858
+ //
1859
+ // Computed at read time rather than written, for the same reason
1860
+ // shopifyMissingScopes is: it becomes true the moment a merchant clears the
1861
+ // list, without waiting for something to notice and write it down.
1862
+ status : ( data ) => ( data?.settings?.list ? data.status : 'pending' ),
1863
+
1864
+ steps : {
527
1865
 
528
- // Klaviyo's list endpoint has no name filter, so a search term is applied
529
- // to what came back. Honest about its own limits: with more lists than
530
- // `limit`, a term matching only later ones finds nothing — which is why
531
- // a genuinely large catalog wants server-side filtering, not this.
532
- const term = String( search?.value || '' ).trim().toLowerCase();
1866
+ contacts : {
533
1867
 
534
- const items = term
535
- ? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
536
- : audiences;
1868
+ // A DECLARATION, not the work. It names the hook that does the work, and
1869
+ // says where that hook's values belong. Nested like the hooks, and the
1870
+ // nesting IS the name: this is `step.contacts.sync`, which is what a
1871
+ // workflow document stores.
1872
+ //
1873
+ // A function, so it can depend on what this deployment or this
1874
+ // merchant's connection knows — a static object would have to be true
1875
+ // for every deployment at publish time.
1876
+ sync : ({ data }) => ({
537
1877
 
538
- return {
539
- items,
540
- pageInfo : {
541
- endCursor : next,
542
- hasNextPage : Boolean( next )
543
- }
544
- };
1878
+ hook : 'contacts.sync',
545
1879
 
546
- },
547
- 'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
1880
+ // The account the merchant actually connected, read back by
1881
+ // auth.connect. The builder reads "Sync contact to Acme Co" rather
1882
+ // than a label that could be any of their Klaviyo accounts.
1883
+ key : 'Sync contact to ' + ( data?.settings?.account || 'Klaviyo' ),
548
1884
 
549
- const token = settings?.refreshToken || settings?.accessToken;
1885
+ queue : 'connection',
550
1886
 
551
- if( ! token ) return { revoked : false };
1887
+ // Nothing for a merchant to configure on the step itself — the list
1888
+ // is chosen once on the connection. Declared empty rather than
1889
+ // omitted, so "this step takes no settings" and "nobody thought about
1890
+ // settings" are different statements.
1891
+ settings : {},
552
1892
 
553
- const response = await fetcher( 'https://a.klaviyo.com/oauth/revoke', {
554
- body : new URLSearchParams({
555
- token,
556
- token_type_hint : settings?.refreshToken ? 'refresh_token' : 'access_token'
557
- }),
558
- headers : {
559
- authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ),
560
- 'content-type' : 'application/x-www-form-urlencoded'
561
- },
562
- method : 'POST',
563
- signal : AbortSignal.timeout( 15000 )
564
- });
1893
+ // BOTH triggers. lead.insert alone only ever fires for someone with
1894
+ // no history yet — a brand-new entrant has no orders and no revenue,
1895
+ // so a profile written then carries an email and nothing else.
1896
+ // Crossing into a segment is the moment the ranking data exists.
1897
+ triggers : [ 'lead.insert', 'segment.contact.add' ],
565
1898
 
566
- // A grant already revoked at the vendor answers non-2xx, and that is
567
- // the outcome we wanted surfacing it as a failure would leave a
568
- // merchant unable to finish disconnecting.
569
- return { revoked : response.ok };
1899
+ // One source for cost: what the builder discloses before a merchant
1900
+ // adds this step, and what is charged when it runs.
1901
+ usage : { actions : 1 }
570
1902
 
571
- },
572
- // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
573
- // what we stored answers the wrong question — a grant revoked inside
574
- // Klaviyo still looks perfect in our database. Spending the refresh token
575
- // is the only thing that asks Klaviyo.
576
- //
577
- // It also keeps the grant warm: Klaviyo expires a refresh token after 90
578
- // days of NON-USE, so a connection nobody touches dies silently without
579
- // this running.
580
- 'auth.probe' : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
581
-
582
- const token = await accessToken({
583
- clientId,
584
- clientSecret,
585
- fetcher,
586
- // Mint even if the stored token still looks good — a probe that
587
- // short-circuits never reaches Klaviyo and reports healthy on a
588
- // grant revoked an hour ago.
589
- force : true,
590
- manifest,
591
- settings
592
- });
593
-
594
- return { ok : Boolean( token ) };
1903
+ })
595
1904
 
596
1905
  }
1906
+
597
1907
  },
598
- icon: icon$3,
599
- // A grant with no list chosen is authenticated and useless. The list cannot
600
- // be part of the consent flow — enumerating lists needs the token the consent
601
- // returns — so it is always a second step, and the card must say so rather
602
- // than showing Active over nothing.
603
- incomplete : ( data ) => ( data?.settings?.list
604
- ? null
605
- : 'Choose which Klaviyo list your contacts should sync into.'
1908
+ // WHY, in the merchant's words, and what to do about it.
1909
+ tasks : ( data ) => ( data?.settings?.list
1910
+ ? []
1911
+ : [
1912
+ {
1913
+ message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
1914
+ title : 'Choose a list'
1915
+ }
1916
+ ]
606
1917
  ),
607
- label : 'klaviyo',
608
- requires : [
609
- 'KLAVIYO_OAUTH_CLIENT_ID',
610
- 'KLAVIYO_OAUTH_CLIENT_SECRET'
611
- ],
612
- setup : [
613
- 'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
614
- 'Sign in to Klaviyo if you are not already, and choose the account to connect.',
615
- 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
616
- 'You can revoke access at any time from Klaviyo, under Integrations.'
617
- ],
618
- slug : 'klaviyo',
619
- // No steps yet. The sync itself is unbuilt, and a step offered in the builder
620
- // that nothing runs is worse than no step at all — the merchant configures it
621
- // and waits for something that never happens.
622
- steps : {},
623
- supports : {
624
- 'auth.connect' : true,
625
- 'auth.disconnect' : true,
626
- // The refresh mint IS the probe: a revoked or rotated grant fails there in
627
- // Klaviyo's own words rather than as an empty sync three steps later.
628
- 'auth.probe' : true,
629
- // Klaviyo scopes are fixed at app level and re-consented, not drifted.
630
- 'auth.scopes' : false,
631
- 'catalog.audiences' : true,
632
- 'catalog.prices' : false,
633
- 'catalog.products' : false,
634
- 'catalog.promotions' : false,
635
- 'inbound.event' : false,
636
- 'inbound.process' : false,
637
- 'inbound.receive' : false,
638
- 'inbound.verify' : false,
639
- 'lifecycle.cleanup' : false,
640
- 'lifecycle.register' : false,
641
- 'lifecycle.rehydrate' : false
642
- },
643
- tasks : () => [
644
- // Mailchimp carries the same warning, deliberately worded the same way. A
645
- // merchant who connects either one and is told nothing reasonably assumes
646
- // contacts are flowing, and finds out weeks later that they are not.
647
- {
648
- 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.',
649
- title : 'List sync not available yet',
650
- type : 'warning'
651
- }
652
- ],
1918
+
653
1919
  title : 'Klaviyo'
654
1920
  };
655
1921
 
@@ -659,7 +1925,7 @@ var klaviyo = {
659
1925
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
660
1926
  // lib/ directly. A bare .svg import would need a bundler loader and force the
661
1927
  // tests onto dist/, which is a worse trade than one line of wrapper.
662
- var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1928
+ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
663
1929
  <rect width="500" height="500" fill="#FFE01B"/>
664
1930
  <path d="M310.633 243.561C312.49 243.336 314.249 243.308 315.882 243.561C316.81 241.394 316.979 237.665 316.149 233.598C314.882 227.561 313.18 223.917 309.662 224.508C306.13 225.071 305.989 229.433 307.269 235.47C307.973 238.847 309.24 241.746 310.633 243.561ZM280.392 248.332C282.925 249.429 284.473 250.147 285.05 249.542C285.458 249.148 285.345 248.36 284.74 247.375C283.112 245.042 280.844 243.229 278.211 242.154C275.397 240.982 272.328 240.556 269.301 240.916C266.274 241.275 263.391 242.41 260.93 244.209C259.256 245.447 257.666 247.15 257.863 248.191C257.961 248.515 258.186 248.782 258.777 248.895C260.17 249.049 265.025 246.587 270.64 246.249C274.608 245.968 277.859 247.206 280.392 248.332ZM275.312 251.216C272.047 251.737 270.232 252.807 269.078 253.848C268.079 254.72 267.488 255.649 267.488 256.339L267.741 256.93L268.262 257.127C269.008 257.127 270.668 256.479 270.668 256.479C275.228 254.861 278.253 255.044 281.25 255.382C282.897 255.579 283.671 255.663 284.037 255.1C284.135 254.931 284.29 254.608 283.938 254.059C283.15 252.778 279.843 250.682 275.312 251.216ZM300.416 261.855C302.654 262.953 305.102 262.502 305.904 260.884C306.735 259.266 305.567 257.056 303.329 255.973C301.106 254.861 298.643 255.283 297.841 256.902C297.039 258.52 298.207 260.757 300.416 261.855ZM314.742 249.317C312.94 249.289 311.449 251.259 311.393 253.764C311.35 256.254 312.8 258.281 314.615 258.295C316.43 258.323 317.922 256.339 317.964 253.862C318.006 251.385 316.571 249.359 314.742 249.317ZM193.103 294.108C192.653 293.545 191.907 293.7 191.189 293.897C190.683 293.995 190.12 294.136 189.515 294.122C188.909 294.134 188.308 293.998 187.767 293.726C187.225 293.454 186.757 293.054 186.405 292.56C185.575 291.294 185.617 289.394 186.546 287.241L186.968 286.27C188.431 283.019 190.838 277.559 188.122 272.367C187.234 270.534 185.908 268.948 184.261 267.75C182.614 266.553 180.697 265.78 178.679 265.5C176.772 265.256 174.835 265.469 173.026 266.123C171.218 266.776 169.591 267.85 168.28 269.257C164.284 273.661 163.679 279.684 164.439 281.823C164.734 282.611 165.184 282.822 165.494 282.864C166.169 282.963 167.169 282.456 167.802 280.754L167.999 280.219C168.28 279.318 168.801 277.63 169.659 276.307C170.717 274.689 172.375 273.558 174.267 273.162C176.159 272.766 178.131 273.138 179.749 274.196C182.563 276.039 183.619 279.473 182.423 282.752C181.803 284.455 180.804 287.691 181.015 290.351C181.466 295.74 184.815 297.907 187.77 298.175C190.669 298.273 192.695 296.655 193.216 295.459C193.511 294.713 193.258 294.277 193.103 294.108Z" fill="#231E15"/>
665
1931
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
@@ -680,11 +1946,6 @@ const base = ( apiKey ) => {
680
1946
  };
681
1947
 
682
1948
  // Mailchimp — contact sync, not a sender.
683
- //
684
- // Deliberately no `group`. Mailchimp and SendGrid shared one while they were
685
- // SENDERS, where an org picking two providers to send the same mail was
686
- // meaningless. As contact syncs they are destinations, and a merchant could
687
- // reasonably keep several up to date, so the exclusivity is gone.
688
1949
  var mailchimp = {
689
1950
  // Keys TODAY. Mailchimp integrations authenticate with OAuth 2 (authorization
690
1951
  // code) and that is where this goes, so the endpoints are recorded here
@@ -705,13 +1966,28 @@ var mailchimp = {
705
1966
  auth : {
706
1967
  type : 'keys'
707
1968
  },
708
- category : 'contacts',
709
- confirm : 'Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
710
- description : [
711
- '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.',
712
- '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.'
713
- ],
714
- excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
1969
+ // EVERYTHING A MERCHANT READS. `errors` belongs in here rather than at the
1970
+ // top level because the connection DOCUMENT carries its own `errors` array
1971
+ // and the document is spread OVER the resolved manifest downstream — a
1972
+ // top-level one would be replaced by that array and never render.
1973
+ content : {
1974
+ confirm : 'Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
1975
+ description : [
1976
+ '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.',
1977
+ '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.'
1978
+ ],
1979
+ excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
1980
+ guide : [
1981
+ 'In Mailchimp, open Account & billing, then Extras, then API keys.',
1982
+ 'Create a key and copy it.',
1983
+ 'Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on.'
1984
+ ]
1985
+ },
1986
+ // Mailchimp and SendGrid shared a group while they were SENDERS, where an org
1987
+ // picking two providers to send the same mail was meaningless. As contact
1988
+ // syncs they are destinations, and a merchant could reasonably keep several
1989
+ // up to date, so the exclusivity is gone.
1990
+ exclusive : false,
715
1991
  feature : 'organization:connection:mailchimp',
716
1992
  fields : [
717
1993
  {
@@ -728,113 +2004,134 @@ var mailchimp = {
728
2004
  key : 'audience',
729
2005
  label : 'Mailchimp audience',
730
2006
  message : 'Contacts your campaigns collect are synced into this audience.',
2007
+ hook : 'resources.audiences',
731
2008
  required : true,
732
- source : 'catalog.audiences'
2009
+ // Mailchimp's /lists takes no name filter either — same reason.
2010
+ search : false
733
2011
  }
734
2012
  ],
735
- // The audiences a merchant can sync into, for the picker on their connection.
736
- //
737
- // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
738
- // it unset returns the first ten audiences and looks entirely successful
739
- // the same silent truncation Klaviyo has, at a different number. Paged
740
- // against total_items so an account past a thousand still resolves.
2013
+ group : 'contacts',
2014
+ // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
2015
+ // else is built yet, because audience sync has not shipped. Every false here
2016
+ // is "not yet" rather than "never" when the sync lands, probe and
2017
+ // contacts.sync are the first to flip.
741
2018
  hooks : {
742
- 'catalog.audiences' : async ({ cursor, fetcher = fetch, limit = 100, search, settings }) => {
743
2019
 
744
- const key = settings?.apiKey;
745
-
746
- // count DEFAULTS TO 10 and maxes at 1000 in Mailchimp's own spec, so
747
- // leaving it unset returns ten audiences and looks entirely successful.
748
- const count = Math.min( limit, 1000 );
749
- const offset = Number( cursor || 0 );
2020
+ auth : {
2021
+ // Implemented outside this package: storing a typed key needs no vendor
2022
+ // call, so the api's own form handler does it.
2023
+ connect : {},
2024
+ disconnect : {},
2025
+ probe : false,
2026
+ scopes : false,
2027
+ // Keys today. When Mailchimp's OAuth lands this becomes a wrapper that
2028
+ // follows the exchange with /oauth2/metadata — the data-centre call that
2029
+ // is the whole reason its OAuth cannot be pure configuration.
2030
+ token : false
2031
+ },
2032
+ commerce : false,
2033
+ contacts : { remove : false, sync : false },
2034
+ // Drawbridge sends its own notification email and SMS, and owns its own
2035
+ // segments — see the private `drawbridge` manifest. A vendor answering
2036
+ // these would be a second sender, which is the arrangement the platform
2037
+ // sender replaced.
2038
+ email : false,
2039
+ segment : false,
2040
+ sms : false,
2041
+ inbound : false,
2042
+ lifecycle : false,
2043
+ resources : {
2044
+
2045
+ // The audiences a merchant can sync into, for the picker on their
2046
+ // connection.
2047
+ //
2048
+ // count DEFAULTS TO 10 and maxes at 1000 (Mailchimp's own OpenAPI spec),
2049
+ // so leaving it unset returns the first ten audiences and looks entirely
2050
+ // successful — the same silent truncation Klaviyo has, at a different
2051
+ // number. Paged against total_items so an account past a thousand still
2052
+ // resolves.
2053
+ audiences : async ({ cursor, fetcher = fetch, limit = 100, search, settings }) => {
2054
+
2055
+ const key = settings?.apiKey;
2056
+
2057
+ const count = Math.min( limit, 1000 );
2058
+ const offset = Number( cursor || 0 );
2059
+
2060
+ const response = await fetcher(
2061
+ base( key ) + '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
2062
+ {
2063
+ // Basic with any username — Mailchimp reads only the password half.
2064
+ headers : { authorization : 'Basic ' + Buffer.from( 'drawbridge:' + key ).toString( 'base64' ) },
2065
+ signal : AbortSignal.timeout( 15000 )
2066
+ }
2067
+ );
750
2068
 
751
- const response = await fetcher(
752
- base( key ) + '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
753
- {
754
- // Basic with any username — Mailchimp reads only the password half.
755
- headers : { authorization : 'Basic ' + Buffer.from( 'drawbridge:' + key ).toString( 'base64' ) },
756
- signal : AbortSignal.timeout( 15000 )
757
- }
758
- );
2069
+ if( ! response.ok ){
759
2070
 
760
- if( ! response.ok ){
2071
+ throw Object.assign(
2072
+ new Error( 'Mailchimp refused the request (' + response.status + ')' ),
2073
+ { status : response.status }
2074
+ );
761
2075
 
762
- throw Object.assign(
763
- new Error( 'Mailchimp refused the request (' + response.status + ')' ),
764
- { status : response.status }
765
- );
2076
+ }
766
2077
 
767
- }
2078
+ const body = await response.json();
768
2079
 
769
- const body = await response.json();
2080
+ const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
770
2081
 
771
- const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
2082
+ // Mailchimp's /lists takes no name filter, so a term is matched against
2083
+ // this page rather than the account. Stated plainly because it is a real
2084
+ // limit: a term matching only an audience on a later page finds nothing.
2085
+ const term = String( search?.value || '' ).trim().toLowerCase();
772
2086
 
773
- // Mailchimp's /lists takes no name filter, so a term is matched against
774
- // this page rather than the account. Stated plainly because it is a real
775
- // limit: a term matching only an audience on a later page finds nothing.
776
- const term = String( search?.value || '' ).trim().toLowerCase();
2087
+ const items = term
2088
+ ? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
2089
+ : audiences;
777
2090
 
778
- const items = term
779
- ? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
780
- : audiences;
2091
+ const nextOffset = offset + count;
2092
+ const more = nextOffset < Number( body?.total_items || 0 );
781
2093
 
782
- const nextOffset = offset + count;
783
- const more = nextOffset < Number( body?.total_items || 0 );
2094
+ return {
2095
+ items,
2096
+ pageInfo : {
2097
+ endCursor : more ? String( nextOffset ) : null,
2098
+ hasNextPage : more
2099
+ }
2100
+ };
784
2101
 
785
- return {
786
- items,
787
- pageInfo : {
788
- endCursor : more ? String( nextOffset ) : null,
789
- hasNextPage : more
790
- }
791
- };
2102
+ },
2103
+ prices : false,
2104
+ products : false,
2105
+ promotions : false
792
2106
 
793
2107
  }
2108
+ ,
2109
+
2110
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
2111
+ webhook : false
794
2112
  },
795
- icon: icon$2,
2113
+ icon: icon$1,
2114
+ slug : 'mailchimp',
796
2115
  // A key with no audience chosen is authenticated and inert. Mailchimp also
797
2116
  // needs its merge fields created on that audience before any Drawbridge total
798
2117
  // can be written to a member — unlike Klaviyo, its custom fields are not
799
2118
  // schemaless — so the audience must be picked before lifecycle.register has
800
2119
  // anything to register against.
801
- incomplete : ( data ) => ( data?.settings?.audience
802
- ? null
803
- : 'Choose which Mailchimp audience your contacts should sync into.'
804
- ),
805
- label : 'mailchimp',
806
- // Uniform surface, honest answers. A key is stored and can be removed; nothing
807
- // else is built yet, because audience sync has not shipped. Every false here
808
- // is "not yet", not "never" — when the sync lands, probe and catalog become
809
- // the first two to flip.
810
- supports : {
811
- 'auth.connect' : true,
812
- 'auth.disconnect' : true,
813
- 'auth.probe' : false,
814
- 'auth.scopes' : false,
815
- 'catalog.audiences' : true,
816
- 'catalog.prices' : false,
817
- 'catalog.products' : false,
818
- 'catalog.promotions' : false,
819
- 'inbound.event' : false,
820
- 'inbound.process' : false,
821
- 'inbound.receive' : false,
822
- 'inbound.verify' : false,
823
- 'lifecycle.cleanup' : false,
824
- 'lifecycle.register' : false,
825
- 'lifecycle.rehydrate' : false
826
- },
827
- setup : [
828
- 'In Mailchimp, open Account & billing, then Extras, then API keys.',
829
- 'Create a key and copy it.',
830
- 'Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on.'
831
- ],
832
- slug : 'mailchimp',
2120
+ status : ( data ) => ( data?.settings?.audience ? data.status : 'pending' ),
833
2121
  // No steps: audience sync has not shipped, so this vendor contributes nothing
834
2122
  // to a workflow yet. An empty steps object is the honest declaration — the
835
2123
  // catalog renders the connection, and no builder offers a step it cannot run.
836
2124
  steps : {},
837
- tasks : () => [
2125
+ tasks : ( data ) => [
2126
+ ...( data?.settings?.audience
2127
+ ? []
2128
+ : [
2129
+ {
2130
+ message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
2131
+ title : 'Choose an audience'
2132
+ }
2133
+ ]
2134
+ ),
838
2135
  {
839
2136
  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.',
840
2137
  title : 'Audience sync not available yet',
@@ -850,7 +2147,7 @@ var mailchimp = {
850
2147
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
851
2148
  // lib/ directly. A bare .svg import would need a bundler loader and force the
852
2149
  // tests onto dist/, which is a worse trade than one line of wrapper.
853
- var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2150
+ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
854
2151
  <rect width="500" height="500" fill="white"/>
855
2152
  <path fill-rule="evenodd" clip-rule="evenodd" d="M309.524 407.192L308.799 128.423C306.921 126.545 303.258 127.112 301.827 127.531L292.29 130.487C291.113 126.613 289.585 122.854 287.726 119.258C280.959 106.337 271.069 99.5052 259.096 99.4866H259.059C258.259 99.4866 257.469 99.5609 256.67 99.626L256.577 99.6353C256.231 99.2089 255.871 98.7935 255.499 98.3897C250.293 92.8125 243.601 90.0889 235.588 90.3213C220.139 90.7675 204.755 101.941 192.271 121.786C183.487 135.757 176.823 153.298 174.917 166.878L144.493 176.313C135.542 179.129 135.263 179.408 134.082 187.858C133.199 194.253 109.766 375.69 109.766 375.69L306.159 409.692L309.524 407.192ZM245.181 103.065C242.569 101.346 239.511 100.546 235.885 100.621C212.033 101.308 191.23 138.611 185.923 163.467L208.771 156.384L212.851 155.119C215.845 139.336 223.355 122.957 233.181 112.416C236.616 108.639 240.671 105.477 245.172 103.065H245.181ZM224.145 151.615L256.94 141.446C257.042 132.894 256.112 120.252 251.836 111.329C247.282 113.207 243.452 116.497 240.7 119.444C233.329 127.373 227.315 139.466 224.155 151.615H224.145ZM267.211 138.267L282.455 133.536C280.02 125.616 274.238 112.342 262.517 110.111C266.161 119.527 267.099 130.431 267.211 138.267Z" fill="#95BF47"/>
856
2153
  <path d="M353.528 149.156C352.356 149.063 329.657 148.709 329.657 148.709C329.657 148.709 310.666 130.249 308.789 128.362C308.062 127.691 307.141 127.268 306.158 127.153V409.64L391.257 388.456C391.257 388.456 356.53 153.366 356.307 151.758C356.199 151.075 355.866 150.448 355.361 149.976C354.856 149.505 354.216 149.216 353.528 149.156Z" fill="#5E8E3E"/>
@@ -956,31 +2253,48 @@ var shopify = {
956
2253
  auth : {
957
2254
  type : 'install'
958
2255
  },
959
- category : 'commerce',
960
- 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.',
961
- // How connecting is DESCRIBED — the copy and destination. What kind of connect
962
- // it is lives in auth.type, once, so the two cannot disagree.
2256
+ // EVERYTHING A MERCHANT READS.
963
2257
  //
964
- // The redirect title is copy: it names where the link GOES rather than what it
965
- // does, since installing happens on the App Store listing and the dashboard
966
- // must never imply a store can be linked from inside it.
967
- connect : {
2258
+ // `errors` is in here rather than at the top level, and that is not a
2259
+ // preference: the connection DOCUMENT carries its own `errors` array of
2260
+ // scope-drift entries, and the document is spread OVER the resolved manifest
2261
+ // downstream — a top-level one would be replaced by that array and never
2262
+ // render.
2263
+ //
2264
+ // `connect` no longer exists as a container. Its other member was `redirect`,
2265
+ // which is a URL and now sits with the vendor's other addresses.
2266
+ content : {
2267
+ 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.',
2268
+ description : [
2269
+ '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.',
2270
+ 'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
2271
+ '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.'
2272
+ ],
968
2273
  errors : {
969
- conflict : 'This store is already connected to another Drawbridge organization.',
970
- currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
971
- invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
2274
+ connect : {
2275
+ conflict : 'This store is already connected to another Drawbridge organization.',
2276
+ currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
2277
+ invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
2278
+ }
972
2279
  },
2280
+ excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
2281
+ guide : [
2282
+ 'Open the Drawbridge listing on the Shopify App Store.',
2283
+ 'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
2284
+ 'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
2285
+ 'Come back here — the connections list updates on its own once the install lands.'
2286
+ ],
2287
+ // Names where the link GOES rather than what it does: installing happens on
2288
+ // the App Store listing, and the dashboard must never imply a store can be
2289
+ // linked from inside it.
973
2290
  redirect : {
974
2291
  env : 'SHOPIFY_APP_LISTING_URL',
975
2292
  title : 'View on the Shopify App Store'
976
2293
  }
977
2294
  },
978
- description : [
979
- '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.',
980
- 'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
981
- '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.'
982
- ],
983
- excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
2295
+ // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
2296
+ // attributed order two possible sources.
2297
+ exclusive : true,
984
2298
  feature : 'organization:connection:shopify',
985
2299
  fields : [
986
2300
  {
@@ -991,12 +2305,9 @@ var shopify = {
991
2305
  label : 'Store domain'
992
2306
  }
993
2307
  ],
994
- group : 'ecommerce',
995
- // The install is the whole configuration Shopify hands back the shop and
996
- // there is nothing further to choose. `shop` absent means the install did not
997
- // finish, which is a credential problem rather than a setup one, so the
998
- // stored status already says so.
999
- incomplete : () => null,
2308
+ // Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
2309
+ // fact, which left nobody able to say which one a page read.
2310
+ group : 'commerce',
1000
2311
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
1001
2312
  // is exactly the shape it covers, so there is nothing vendor-specific to
1002
2313
  // write for either. receive is the one hook that genuinely differs by
@@ -1004,8 +2315,50 @@ var shopify = {
1004
2315
  // /compliance enforces the topic allowlist above, because answering one late
1005
2316
  // is a legal deadline rather than a retry.
1006
2317
  hooks : {
1007
- 'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
1008
- 'inbound.receive' : ({ channel, event, headers, payload }) => {
2318
+
2319
+ auth : {
2320
+ // The install completes inside Shopify admin; the api's callback stores
2321
+ // what it hands back. auth.probe is false deliberately: the health check
2322
+ // re-registers rather than answering "is this token still good", and
2323
+ // scope drift is its own hook because a token can be perfectly valid
2324
+ // while the grant is too narrow.
2325
+ connect : {},
2326
+ disconnect : {},
2327
+ probe : false,
2328
+ scopes : {},
2329
+ // Shopify's install grant is exchanged inside its own app flow, not
2330
+ // through the shared OAuth runner.
2331
+ token : false
2332
+ },
2333
+ // Implemented in drawbridge-sync, which owns the attribution and the
2334
+ // controllers it needs. Declared here so the steps below can point at them:
2335
+ // a step naming a hook the vendor does not implement is a workflow that
2336
+ // accepts the step and then silently does nothing.
2337
+ commerce : {
2338
+ code : {},
2339
+ customer : {},
2340
+ order : {},
2341
+ product : {}
2342
+ },
2343
+ contacts : { remove : false, sync : false },
2344
+
2345
+ // verify and event lean entirely on the shared HMAC helper — Shopify's
2346
+ // scheme is exactly the shape it covers, so there is nothing vendor-specific
2347
+ // to write for either. receive is the one hook that genuinely differs by
2348
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
2349
+ // /compliance enforces the topic allowlist above, because answering one late
2350
+ // is a legal deadline rather than a retry.
2351
+ // Drawbridge sends its own notification email and SMS, and owns its own
2352
+ // segments — see the private `drawbridge` manifest. A vendor answering
2353
+ // these would be a second sender, which is the arrangement the platform
2354
+ // sender replaced.
2355
+ email : false,
2356
+ segment : false,
2357
+ sms : false,
2358
+ inbound : {
2359
+ event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
2360
+ process : {},
2361
+ receive : ({ channel, event, headers, payload }) => {
1009
2362
 
1010
2363
  if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
1011
2364
 
@@ -1024,12 +2377,104 @@ var shopify = {
1024
2377
  provider : { id : headers[ inbound.headers.id ] || null }
1025
2378
  };
1026
2379
 
2380
+ },
2381
+ verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
1027
2382
  },
1028
- 'inbound.verify' : ( args ) => verifySignature({ ...args, descriptor : inbound })
2383
+ lifecycle : { cleanup : {}, health : {}, register : {}, rehydrate : {} },
2384
+ resources : {
2385
+ audiences : false,
2386
+ // Shopify has no separate price resource — a price belongs to a product
2387
+ // variant and arrives with it, so there is nothing for prices to answer
2388
+ // that products does not already.
2389
+ prices : false,
2390
+
2391
+ // WHAT THE VENDOR ANSWERS, shaped for a picker. Both of these were api
2392
+ // ROUTES — /organization/:organization/shopify/products and
2393
+ // .../connection/:id/shopify/discounts — vendor-named urls in a service
2394
+ // that is supposed to have none, reachable only by knowing the path.
2395
+ // They are the same two questions every other vendor answers through
2396
+ // resources.*, so they answer them the same way now.
2397
+ //
2398
+ // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
2399
+ // which depends on it. What arrives is the SDK's pure HTTP namespaces
2400
+ // and nothing else — no controller, no collection access. Resolving the
2401
+ // credential is the caller's job because it is Drawbridge's job: the
2402
+ // admin token refreshes and writes itself back, which is service work,
2403
+ // not vendor work.
2404
+ products : async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
2405
+
2406
+ const products = await shopify.storefront.getProducts({
2407
+ cursor,
2408
+ domain : settings?.domain,
2409
+ limit : Number( limit ),
2410
+ search : search?.value || null,
2411
+ sort,
2412
+ storefrontAccessToken : settings?.storefrontAccessToken
2413
+ });
2414
+
2415
+ return {
2416
+ items : ( products?.edges || [] ).map( ( edge ) => edge.node ),
2417
+ pageInfo : {
2418
+ endCursor : products?.pageInfo?.endCursor || null,
2419
+ hasNextPage : Boolean( products?.pageInfo?.hasNextPage ),
2420
+ hasPreviousPage : Boolean( products?.pageInfo?.hasPreviousPage ),
2421
+ startCursor : products?.pageInfo?.startCursor || null
2422
+ }
2423
+ };
2424
+
2425
+ },
2426
+
2427
+ promotions : async ({ cursor, limit = 100, search, settings, shopify }) => {
2428
+
2429
+ const discounts = await shopify.admin.getDiscounts({
2430
+ adminAccessToken : settings?.adminAccessToken,
2431
+ cursor,
2432
+ domain : settings?.domain,
2433
+ limit : Number( limit ),
2434
+ search : search?.value || null
2435
+ });
2436
+
2437
+ return {
2438
+ // The GLOBAL id is what Shopify returns and the bare id is what a
2439
+ // picker stores, which is why the tail is taken here rather than by
2440
+ // each caller that happened to remember.
2441
+ items : ( discounts?.edges || [] ).map( ( edge ) => ({
2442
+ id : String( edge?.node?.id || '' ).split( '/' ).pop(),
2443
+ title : edge?.node?.codeDiscount?.title
2444
+ }) ),
2445
+ pageInfo : {
2446
+ endCursor : discounts?.pageInfo?.endCursor || null,
2447
+ hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
2448
+ }
2449
+ };
2450
+
2451
+ }
2452
+ }
2453
+ ,
2454
+
2455
+ // Drawbridge posts to a merchant's own endpoint, never through a vendor.
2456
+ webhook : false
1029
2457
  },
1030
- icon: icon$1,
2458
+ icon,
1031
2459
  inbound,
1032
- label : 'shopify',
2460
+ // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
2461
+ //
2462
+ // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
2463
+ // in the shared resolver — a hardcoded vendor branch in code every vendor runs
2464
+ // through, which is the arrangement these manifests exist to remove.
2465
+ //
2466
+ // Undefined until a shop is linked, so the Manage button only appears on a
2467
+ // connected connection. The app handle is NAMED by `requires` and read from
2468
+ // the env the resolver passes, never from process.env here.
2469
+ manage : ( data, env ) => {
2470
+
2471
+ const shop = data?.shop || data?.settings?.domain;
2472
+
2473
+ return shop
2474
+ ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
2475
+ : undefined;
2476
+
2477
+ },
1033
2478
  // A pre-launch integration: it only surfaces once the App Store listing
1034
2479
  // exists and the app is fully configured. Requiring all four means it can
1035
2480
  // never render half-configured — and absence of any one excludes the
@@ -1040,139 +2485,150 @@ var shopify = {
1040
2485
  'SHOPIFY_APP_LISTING_URL',
1041
2486
  'SHOPIFY_APP_HANDLE'
1042
2487
  ],
1043
- // The only vendor implementing most of the surface, which is why it was the
1044
- // one every slug branch in three repos was written for.
1045
- //
1046
- // auth.probe is false deliberately: the health check re-registers webhooks
1047
- // rather than answering "is this token still good", and scope drift is its own
1048
- // hook because a token can be perfectly valid while the grant is too narrow.
1049
- supports : {
1050
- 'auth.connect' : true,
1051
- 'auth.disconnect' : true,
1052
- 'auth.probe' : false,
1053
- 'auth.scopes' : true,
1054
- // Shopify has no separate price resource — a price belongs to a product
1055
- // variant and arrives with it, so there is nothing for prices to answer
1056
- // that products does not already.
1057
- 'catalog.audiences' : false,
1058
- 'catalog.prices' : false,
1059
- 'catalog.products' : true,
1060
- 'catalog.promotions' : true,
1061
- 'inbound.event' : true,
1062
- 'inbound.process' : true,
1063
- 'inbound.receive' : true,
1064
- 'inbound.verify' : true,
1065
- 'lifecycle.cleanup' : true,
1066
- 'lifecycle.register' : true,
1067
- 'lifecycle.rehydrate' : true
1068
- },
1069
- setup : [
1070
- 'Open the Drawbridge listing on the Shopify App Store.',
1071
- 'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
1072
- 'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
1073
- 'Come back here — the connections list updates on its own once the install lands.'
1074
- ],
1075
2488
  slug : 'shopify',
2489
+ // The install is the whole configuration — Shopify hands back the shop and
2490
+ // there is nothing further to choose. `shop` absent means the install did not
2491
+ // finish, which is a credential problem rather than a setup one, so the
2492
+ // stored status already says so.
2493
+ // Nothing to add — no setting can make this connection unusable, so the
2494
+ // credential's own verdict stands.
2495
+ status : ( data ) => data?.status,
1076
2496
  // Step types name the CAPABILITY, not this vendor. A second store platform
1077
2497
  // implements the same four commerce steps, and the connection on the step
1078
2498
  // says which store it runs against — so a merchant sees one "Create
1079
2499
  // customer", not one per platform. The three connection.* steps are not
1080
2500
  // commerce at all: any vendor holding a rotating credential needs them.
2501
+ // Step types name the CAPABILITY, not this vendor. A second store platform
2502
+ // implements the same commerce steps, and the connection on the step says
2503
+ // which store it runs against — so a merchant sees one "Create customer", not
2504
+ // one per platform.
2505
+ //
2506
+ // Nested for readability and flattened to the stored name, at whatever depth:
2507
+ // steps.commerce.customer.insert is `step.commerce.customer.insert` on a
2508
+ // workflow document, and those strings cannot be renamed without a backfill.
2509
+ //
2510
+ // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2511
+ // The bodies these point at still live in drawbridge-sync; moving them is the
2512
+ // next unit, and commerce.order.record is the one that decides whether the
2513
+ // shape holds — 569 lines and 15 controller calls.
1081
2514
  steps : {
1082
- 'step.commerce.customer.insert' : {
1083
- billable : true,
1084
- key : 'Create customer',
1085
- queue : 'connection',
1086
- returns : [
1087
- { key : 'shopifyCustomerId', label : 'Shopify Customer ID' }
1088
- ],
1089
- settings : {},
1090
- triggers : [ 'lead.insert' ]
1091
- },
1092
- 'step.commerce.code.issue' : {
1093
- billable : true,
1094
- key : 'Issue a discount code',
1095
- queue : 'connection',
1096
- returns : [
1097
- { key : 'shopifyDiscountCode', label : 'Shopify Discount Code' },
1098
- { key : 'shopifyDiscountId', label : 'Shopify Discount ID' }
1099
- ],
1100
- settings : {
1101
- discount : {
1102
- required : true,
1103
- shape : {
1104
- id : { required : true, type : 'string' }
2515
+
2516
+ commerce : {
2517
+
2518
+ code : {
2519
+ issue : () => ({
2520
+ hook : 'commerce.code',
2521
+ key : 'Issue a discount code',
2522
+ queue : 'connection',
2523
+ settings : {
2524
+ discount : {
2525
+ required : true,
2526
+ shape : {
2527
+ id : { required : true, type : 'string' }
2528
+ },
2529
+ type : 'object'
2530
+ }
1105
2531
  },
1106
- type : 'object'
1107
- }
2532
+ triggers : [ 'lead.insert' ],
2533
+ usage : { actions : 1 }
2534
+ })
1108
2535
  },
1109
- triggers : [ 'lead.insert' ]
1110
- },
1111
- // System steps: dispatched by sync itself rather than offered in the
1112
- // builder, so they carry no trigger. They are declared because the
1113
- // routing table and the system-workflow descriptions both read from here.
1114
- // Not a webhook monitor, despite the name it carried. Webhooks are
1115
- // declarative — declared in the app's toml, applied by Shopify to every
1116
- // install so nothing registers or checks them here. This rotates the
1117
- // access token before Shopify's idle window closes, and reconciles the
1118
- // scopes the store granted against the ones the app now needs.
1119
- 'step.connection.health.check' : {
1120
- description : 'Keeps store access working — refreshes the access token before it goes stale and reports when the store\'s approved permissions fall behind.',
1121
- key : 'Shopify Connection Health',
1122
- queue : 'connection',
1123
- system : true
1124
- },
1125
- 'step.commerce.order.record' : {
1126
- description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
1127
- key : 'Shopify Order Tracking',
1128
- queue : 'connection',
1129
- system : true
1130
- },
1131
- 'step.commerce.product.sync' : {
1132
- description : 'Syncs Shopify product data on webhook updates.',
1133
- key : 'Shopify Product Sync',
1134
- queue : 'connection',
1135
- system : true
1136
- },
1137
- // Audit-only. The "Shopify Token Activity" system workflow lists these for
1138
- // descriptive grouping, but its audit step docs are written manually at
1139
- // OAuth time — the workflow is never dispatched. Routing is declared
1140
- // defensively so that if it ever IS dispatched, the job lands on a real
1141
- // queue and the handler lookup misses cleanly instead of throwing
1142
- // "Unknown step type".
1143
- 'step.connection.token.exchange' : {
1144
- key : 'Shopify Token Exchange',
1145
- queue : 'connection',
1146
- system : true
2536
+
2537
+ customer : {
2538
+ insert : () => ({
2539
+ hook : 'commerce.customer',
2540
+ key : 'Create customer',
2541
+ queue : 'connection',
2542
+ settings : {},
2543
+ triggers : [ 'lead.insert' ],
2544
+ usage : { actions : 1 }
2545
+ })
2546
+ },
2547
+
2548
+ // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
2549
+ // in the builder, so they carry no trigger and no usage. Declared because
2550
+ // the routing table and the system-workflow descriptions both read here.
2551
+ order : {
2552
+ record : () => ({
2553
+ description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
2554
+ hook : 'commerce.order',
2555
+ key : 'Shopify Order Tracking',
2556
+ queue : 'connection',
2557
+ system : true
2558
+ })
2559
+ },
2560
+
2561
+ product : {
2562
+ sync : () => ({
2563
+ description : 'Syncs Shopify product data on webhook updates.',
2564
+ hook : 'commerce.product',
2565
+ key : 'Shopify Product Sync',
2566
+ queue : 'connection',
2567
+ system : true
2568
+ })
2569
+ }
2570
+
1147
2571
  },
1148
- 'step.connection.token.refresh' : {
1149
- key : 'Shopify Token Refresh',
1150
- queue : 'connection',
1151
- system : true
2572
+
2573
+ connection : {
2574
+
2575
+ // Not a webhook monitor, despite the name it once carried. Webhooks are
2576
+ // declarative — declared in the app's toml, applied by Shopify to every
2577
+ // install — so nothing registers or checks them here. This rotates the
2578
+ // access token before Shopify's idle window closes, and reconciles the
2579
+ // scopes the store granted against the ones the app now needs.
2580
+ health : {
2581
+ check : () => ({
2582
+ description : 'Keeps store access working — refreshes the access token before it goes stale and reports when the store\'s approved permissions fall behind.',
2583
+ hook : 'lifecycle.health',
2584
+ key : 'Shopify Connection Health',
2585
+ queue : 'connection',
2586
+ system : true
2587
+ })
2588
+ },
2589
+
2590
+ // Audit-only. The "Shopify Token Activity" system workflow lists these
2591
+ // for descriptive grouping, but its audit step docs are written manually
2592
+ // at OAuth time — the workflow is never dispatched. Routing is declared
2593
+ // defensively so that if it ever IS dispatched, the job lands on a real
2594
+ // queue and the handler lookup misses cleanly instead of throwing
2595
+ // "Unknown step type".
2596
+ token : {
2597
+ exchange : () => ({
2598
+ description : 'Records the token exchange that completed an install. Audit only — never dispatched.',
2599
+ key : 'Shopify Token Exchange',
2600
+ queue : 'connection',
2601
+ system : true
2602
+ }),
2603
+ refresh : () => ({
2604
+ description : 'Records a token rotation. Audit only — never dispatched.',
2605
+ key : 'Shopify Token Refresh',
2606
+ queue : 'connection',
2607
+ system : true
2608
+ })
2609
+ }
2610
+
1152
2611
  }
2612
+
1153
2613
  },
2614
+ // Shopify sits pending between the install landing and the merchant choosing a
2615
+ // plan, and nothing on our side can move it — so the card says what they need
2616
+ // to go and do rather than showing Pending with no next step.
2617
+ //
2618
+ // Scope drift is NOT here: drawbridge-sync writes it onto the connection
2619
+ // document, and the document's own warnings render beside these.
2620
+ tasks : ( data ) => ( data?.status === 'pending'
2621
+ ? [
2622
+ {
2623
+ message : 'Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.',
2624
+ title : 'Choose a plan in Shopify'
2625
+ }
2626
+ ]
2627
+ : []
2628
+ ),
1154
2629
  title : 'Shopify'
1155
2630
  };
1156
2631
 
1157
- // Drawbridge, exported from the brand kit and left as authored — the fills are the
1158
- // vendor's own mark, not a recolour.
1159
- //
1160
- // A .js wrapper around otherwise untouched SVG so `node --test` can run against
1161
- // lib/ directly. A bare .svg import would need a bundler loader and force the
1162
- // tests onto dist/, which is a worse trade than one line of wrapper.
1163
- var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1164
- <rect width="500" height="500" fill="#BAEC5F"/>
1165
- <g clip-path="url(#clip0_2115_2832)">
1166
- <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"/>
1167
- <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"/>
1168
- </g>
1169
- <defs>
1170
- <clipPath id="clip0_2115_2832">
1171
- <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
1172
- </clipPath>
1173
- </defs>
1174
- </svg>`;
1175
-
1176
2632
  // Webhooks — the only connection with no third party behind it. Connecting
1177
2633
  // generates a signing secret rather than asking for a credential, which is why
1178
2634
  // its one field declares no `input`.
@@ -1188,13 +2644,26 @@ var webhook = {
1188
2644
  auth : {
1189
2645
  type : 'generated'
1190
2646
  },
1191
- category : 'developer',
1192
- confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
1193
- description : [
1194
- 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
1195
- '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.'
1196
- ],
1197
- excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
2647
+ // EVERYTHING A MERCHANT READS. `errors` would belong here too — the
2648
+ // connection DOCUMENT carries its own `errors` array and is spread OVER the
2649
+ // resolved manifest downstream, so a top-level one is replaced by that array.
2650
+ content : {
2651
+ confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
2652
+ description : [
2653
+ 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
2654
+ '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.'
2655
+ ],
2656
+ excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
2657
+ guide : [
2658
+ 'Press Connect. Drawbridge generates a signing secret and shows it here.',
2659
+ 'Copy the secret into your own endpoint.',
2660
+ '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.'
2661
+ ]
2662
+ },
2663
+ // Nothing to be exclusive with — there is no second webhook vendor, and a
2664
+ // merchant with two endpoints is a step-level choice rather than a second
2665
+ // connection.
2666
+ exclusive : false,
1198
2667
  feature : 'organization:connection:webhook',
1199
2668
  fields : [
1200
2669
  {
@@ -1207,14 +2676,91 @@ var webhook = {
1207
2676
  label : 'Signing secret'
1208
2677
  }
1209
2678
  ],
2679
+ group : 'developer',
2680
+ // OUTBOUND ONLY. inbound.* is false because the direction is the point: we
2681
+ // sign and POST to the merchant's endpoint, they never call us. Every other
2682
+ // false follows from there being no third party to authenticate against —
2683
+ // connect generates a secret rather than proving a credential.
2684
+ hooks : {
2685
+ auth : {
2686
+ // Minting and clearing a secret needs no vendor call, so the api's own
2687
+ // handler does both.
2688
+ connect : {},
2689
+ disconnect : {},
2690
+ probe : false,
2691
+ scopes : false,
2692
+ // Nothing to mint. Connecting generates a secret; there is no vendor.
2693
+ token : false
2694
+ },
2695
+ commerce : false,
2696
+ contacts : { remove : false, sync : false },
2697
+ // Drawbridge sends its own notification email and SMS, and owns its own
2698
+ // segments — see the private `drawbridge` manifest. A vendor answering
2699
+ // these would be a second sender, which is the arrangement the platform
2700
+ // sender replaced.
2701
+ email : false,
2702
+ segment : false,
2703
+ sms : false,
2704
+ inbound : false,
2705
+ lifecycle : false,
2706
+ resources : {
2707
+ audiences : false,
2708
+ prices : false,
2709
+ products : false,
2710
+ promotions : false
2711
+ },
2712
+ // THE BODY IS HERE, not in drawbridge-sync. It needs `crypto` and an HTTP
2713
+ // client and nothing else — no controller, no queue, no database — so
2714
+ // there was never a reason for it to live in another repo.
2715
+ //
2716
+ // That is the rule the whole split runs on: a hook lives in sync only if it
2717
+ // needs Drawbridge's own database, sockets or queues. This one does not.
2718
+ webhook : {
2719
+
2720
+ send : async ({ context, controller, request : send = safeRequest, settings, step }) => {
2721
+
2722
+ const { headers = {}, method = 'POST', url } = step.settings || {};
2723
+
2724
+ const request = { method, url : url || null };
2725
+
2726
+ if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
2727
+
2728
+ // The LEAD, when there is one, rather than the accumulated context. A
2729
+ // receiver wants the entrant's record, not our internal step state.
2730
+ const lead = context?.lead
2731
+ ? await controller.get({ collection : 'lead', query : { id : context.lead } })
2732
+ : null;
2733
+
2734
+ const body = lead || context;
2735
+
2736
+ request.body = body;
2737
+
2738
+ const outgoing = { ...headers };
2739
+
2740
+ // Signed with the connection's secret when it has one. A payload nobody can
2741
+ // verify is an unsigned payload, which is why the connection's own task card
2742
+ // tells the merchant to check it.
2743
+ if( settings?.secret ){
2744
+
2745
+ outgoing[ 'X-Drawbridge-Signature' ] = 'sha256=' + crypto
2746
+ .createHmac( 'sha256', settings.secret )
2747
+ .update( JSON.stringify( body ) )
2748
+ .digest( 'hex' );
2749
+
2750
+ }
2751
+
2752
+ const response = await send({ body, headers : outgoing, method, url });
2753
+
2754
+ return { message : 'Webhook POSTed to ' + url + '.', request, response : response || { delivered : true } };
2755
+
2756
+
2757
+ }
2758
+ }
2759
+ },
1210
2760
  // Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
1211
2761
  // It is the one card that reads wrong — our logo among vendor logos — and it
1212
2762
  // wants a mark of its own when there is one.
1213
- icon,
1214
- // The destination url is supplied per step, not per connection, so there is
1215
- // nothing to finish here — generating the secret IS connecting.
1216
- incomplete : () => null,
1217
- label : 'webhook',
2763
+ icon: icon$3,
1218
2764
  // Gated on the encryption secret: without it the signing secret could not be
1219
2765
  // stored safely, so the connection must not be offered at all.
1220
2766
  requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
@@ -1222,39 +2768,28 @@ var webhook = {
1222
2768
  // sign and POST to the merchant's endpoint, they never call us. Every other
1223
2769
  // false follows from there being no third party to authenticate against —
1224
2770
  // connect generates a secret rather than proving a credential.
1225
- supports : {
1226
- 'auth.connect' : true,
1227
- 'auth.disconnect' : true,
1228
- 'auth.probe' : false,
1229
- 'auth.scopes' : false,
1230
- 'catalog.audiences' : false,
1231
- 'catalog.prices' : false,
1232
- 'catalog.products' : false,
1233
- 'catalog.promotions' : false,
1234
- 'inbound.event' : false,
1235
- 'inbound.process' : false,
1236
- 'inbound.receive' : false,
1237
- 'inbound.verify' : false,
1238
- 'lifecycle.cleanup' : false,
1239
- 'lifecycle.register' : false,
1240
- 'lifecycle.rehydrate' : false
1241
- },
1242
- setup : [
1243
- 'Press Connect. Drawbridge generates a signing secret and shows it here.',
1244
- 'Copy the secret into your own endpoint.',
1245
- '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.'
1246
- ],
1247
2771
  slug : 'webhook',
2772
+ // The destination url is supplied per step, not per connection, so there is
2773
+ // nothing to finish here — generating the secret IS connecting, and no
2774
+ // setting can make this connection unusable. The credential's own verdict
2775
+ // stands.
2776
+ status : ( data ) => data?.status,
1248
2777
  steps : {
1249
- 'step.webhook.send' : {
1250
- billable : true,
1251
- key : 'Send webhook',
1252
- queue : 'webhook',
1253
- returns : [],
1254
- settings : {
1255
- url : { format : 'url', required : true, type : 'string' }
1256
- },
1257
- triggers : [ 'lead.insert', 'lead.delete' ]
2778
+ webhook : {
2779
+ send : () => ({
2780
+ hook : 'webhook.send',
2781
+ key : 'Send webhook',
2782
+ queue : 'webhook',
2783
+ settings : {
2784
+ url : { format : 'url', required : true, type : 'string' }
2785
+ },
2786
+ triggers : [ 'lead.insert', 'lead.delete' ],
2787
+ // Replaces `billable : true`, which fed BILLABLE_STEP_TYPES, which set
2788
+ // workflow.billable at save, which sync then checked against a usage
2789
+ // the handler returned — three hops for one fact, two of which could
2790
+ // disagree silently.
2791
+ usage : { actions : 1 }
2792
+ })
1258
2793
  }
1259
2794
  },
1260
2795
  // The card the connection page raises. Before connecting it explains what
@@ -1304,9 +2839,30 @@ var webhook = {
1304
2839
 
1305
2840
  // The queues sync runs. A step routes to exactly one, and naming them here is
1306
2841
  // what lets sync derive its routing table instead of maintaining a parallel copy
1307
- // that can fall out of step with the catalog.
2842
+ // that can fall out of step with the resources.
1308
2843
  const QUEUES = [ 'connection', 'notification', 'segment', 'webhook' ];
1309
2844
 
2845
+ // A HOOK'S VALUE IS ITS ANSWER — false is a decision recorded, a function is the
2846
+ // body, and {} means supported but implemented in the repo holding the vendor's
2847
+ // dependencies. Anything else, including absence, is not implemented.
2848
+ const implemented = ( hooks, path ) => {
2849
+
2850
+ const hook = path.split( '.' ).reduce( ( node, key ) => node?.[ key ], hooks );
2851
+
2852
+ return typeof hook === 'function' || ( !! hook && typeof hook === 'object' );
2853
+
2854
+ };
2855
+
2856
+ // Walk a nested map to its FUNCTION leaves, returning [ 'a.b.c', fn ] pairs.
2857
+ // Depth is whatever the vendor wrote, because stored step types run three and
2858
+ // four segments deep and those strings cannot be renamed without a backfill.
2859
+ const leaves = ( node, path = [] ) => Object.entries( node || {} ).flatMap(
2860
+ ( [ key, value ] ) => ( typeof value === 'function'
2861
+ ? [ [ [ ...path, key ].join( '.' ), value ] ]
2862
+ : value && typeof value === 'object' ? leaves( value, [ ...path, key ] ) : []
2863
+ )
2864
+ );
2865
+
1310
2866
  // WHAT A CONNECTION MUST DECLARE. Thrown at import rather than discovered by a
1311
2867
  // merchant looking at a broken card, or by a workflow that accepted a step it
1312
2868
  // could never run.
@@ -1314,12 +2870,24 @@ const build = ( manifest ) => {
1314
2870
 
1315
2871
  if( ! manifest?.slug ) throw new Error( 'A connection needs a slug' );
1316
2872
  if( ! manifest?.title ) throw new Error( manifest.slug + ' needs a title' );
1317
- if( ! manifest?.feature ) throw new Error( manifest.slug + ' needs a plan feature key' );
1318
- if( ! manifest?.excerpt ) throw new Error( manifest.slug + ' needs an excerpt for its card' );
2873
+ // A PRIVATE connection is Drawbridge itself: always present, in every
2874
+ // deployment, for every organization. It contributes steps and never appears
2875
+ // in the catalog, so the two questions a public connection answers — can this
2876
+ // merchant connect it (`requires`) and does their plan allow it (`feature`) —
2877
+ // have no meaning and are not asked.
2878
+ if( ! manifest?.private && ! manifest?.feature ) throw new Error( manifest.slug + ' needs a plan feature key' );
2879
+ // EVERYTHING A MERCHANT READS lives under `content`, so the question on a new
2880
+ // vendor is "does a person read this" rather than "is this marketing".
2881
+ //
2882
+ // `errors` is in there for a harder reason than tidiness: the connection
2883
+ // DOCUMENT carries its own `errors` array, and the document is spread OVER the
2884
+ // resolved manifest downstream — a top-level `errors` here would be replaced
2885
+ // by that array and never render.
2886
+ if( ! manifest?.content?.excerpt ) throw new Error( manifest.slug + ' needs content.excerpt for its card' );
1319
2887
 
1320
- if( ! Array.isArray( manifest?.description ) || ! manifest.description.length ){
2888
+ if( ! Array.isArray( manifest?.content?.description ) || ! manifest.content.description.length ){
1321
2889
 
1322
- throw new Error( manifest.slug + ' needs a description — an array of paragraphs for its page' );
2890
+ throw new Error( manifest.slug + ' needs content.description — an array of paragraphs for its page' );
1323
2891
 
1324
2892
  }
1325
2893
 
@@ -1347,26 +2915,46 @@ const build = ( manifest ) => {
1347
2915
  // merchant, at form time. A Klaviyo list cannot be a static list here —
1348
2916
  // it exists in their account, not in this package — and the alternative is
1349
2917
  // asking a merchant to paste an id from another browser tab.
1350
- if( field.input === 'select' && ! ( field.options || [] ).length && ! field.source ){
2918
+ if( field.input === 'select' && ! ( field.options || [] ).length && ! field.hook ){
1351
2919
 
1352
- throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a source' );
2920
+ throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a hook' );
1353
2921
 
1354
2922
  }
1355
2923
 
1356
- // A source names a hook, and the manifest must actually support it —
1357
- // otherwise the form asks for choices from something that will answer
1358
- // `unsupported`, and the merchant sees a dropdown that never fills.
1359
- if( field.source ){
2924
+ // A HOOK, named outright. ListResource and InputResource take an
2925
+ // `endpoint`, and the client composes one from this building a path from
2926
+ // a name, rather than the manifest carrying the api's route shape. That
2927
+ // route has already moved twice; a path here would ship the old one to
2928
+ // five repos that upgrade at different times.
2929
+ //
2930
+ // It also lifts a limit nobody decided: a connection needing a list AND a
2931
+ // segment is two fields with two hooks, where one `source` per form
2932
+ // quietly loaded only the first.
2933
+ if( field.hook ){
2934
+
2935
+ if( ! HOOK_NAMES.includes( field.hook ) ){
2936
+
2937
+ throw new Error( manifest.slug + '.' + field.key + ' names an unknown hook: ' + field.hook );
1360
2938
 
1361
- if( ! HOOK_NAMES.includes( field.source ) ){
2939
+ }
2940
+
2941
+ // The picker route is a GET, and a GET must be safe to repeat — Next
2942
+ // prefetches, proxies retry. `resources.*` is the read-only domain, so a
2943
+ // field cannot point a dropdown at auth.disconnect. `supports` alone
2944
+ // would not catch that: Klaviyo really does support auth.disconnect.
2945
+ if( ! field.hook.startsWith( 'resources.' ) ){
1362
2946
 
1363
- throw new Error( manifest.slug + '.' + field.key + ' sources options from an unknown hook: ' + field.source );
2947
+ throw new Error( manifest.slug + '.' + field.key + ' reads from ' + field.hook + ' a picker may only read resources.*' );
1364
2948
 
1365
2949
  }
1366
2950
 
1367
- if( manifest.supports?.[ field.source ] !== true ){
2951
+ // ASK THE HOOK ITSELF. This used to consult a parallel `supports` map,
2952
+ // which is two places to say one thing and therefore two places to
2953
+ // disagree — a picker pointed at a hook the vendor had since removed
2954
+ // still passed, because the map still said true.
2955
+ if( ! implemented( manifest.hooks, field.hook ) ){
1368
2956
 
1369
- throw new Error( manifest.slug + '.' + field.key + ' sources options from ' + field.source + ', which it declares unsupported' );
2957
+ throw new Error( manifest.slug + '.' + field.key + ' reads ' + field.hook + ', which this vendor does not implement' );
1370
2958
 
1371
2959
  }
1372
2960
 
@@ -1402,9 +2990,12 @@ const build = ( manifest ) => {
1402
2990
 
1403
2991
  }
1404
2992
 
1405
- if( ! CATEGORIES.includes( manifest?.category ) ){
2993
+ // `group` does the actual grouping on the connections page, and `category`
2994
+ // was a second word for the same fact — so it is gone rather than kept as an
2995
+ // alias nobody could tell apart from this one.
2996
+ if( ! GROUPS.includes( manifest?.group ) ){
1406
2997
 
1407
- throw new Error( manifest.slug + ' needs a category — one of ' + CATEGORIES.join( ', ' ) );
2998
+ throw new Error( manifest.slug + ' needs a group — one of ' + GROUPS.join( ', ' ) );
1408
2999
 
1409
3000
  }
1410
3001
 
@@ -1439,6 +3030,25 @@ const build = ( manifest ) => {
1439
3030
 
1440
3031
  }
1441
3032
 
3033
+ // THE TOKEN REQUEST IS A HOOK, and an oauth vendor that does not implement
3034
+ // it cannot mint anything. Refused here rather than discovered at the
3035
+ // callback, which is after the merchant has consented and left.
3036
+ if( typeof manifest.hooks?.auth?.token !== 'function' ){
3037
+
3038
+ throw new Error( manifest.slug + ' is oauth and must implement hooks.auth.token — point it at authToken() or wrap it' );
3039
+
3040
+ }
3041
+
3042
+ for( const url of OAUTH_URLS ){
3043
+
3044
+ if( ! manifest.auth.oauth?.urls?.[ url ] ){
3045
+
3046
+ throw new Error( manifest.slug + ' is oauth and must declare auth.oauth.urls.' + url );
3047
+
3048
+ }
3049
+
3050
+ }
3051
+
1442
3052
  // THE REDIRECT MUST BE THE ONE PATH THE DASHBOARD SERVES.
1443
3053
  //
1444
3054
  // There is exactly one callback route — app/api/connection/[slug]/callback
@@ -1451,10 +3061,10 @@ const build = ( manifest ) => {
1451
3061
  // registered at the vendor, and fails AFTER the merchant has consented —
1452
3062
  // they hit a dashboard 404 having already granted access. Cheaper to refuse
1453
3063
  // the manifest at import.
1454
- if( manifest.auth.oauth.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
3064
+ if( manifest.auth.oauth.urls.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
1455
3065
 
1456
3066
  throw new Error(
1457
- manifest.slug + ' declares auth.oauth.redirect ' + manifest.auth.oauth.redirect
3067
+ manifest.slug + ' declares auth.oauth.urls.redirect ' + manifest.auth.oauth.urls.redirect
1458
3068
  + ' but the only callback route is /api/connection/' + manifest.slug + '/callback'
1459
3069
  );
1460
3070
 
@@ -1465,15 +3075,15 @@ const build = ( manifest ) => {
1465
3075
  // A vendor that receives from the outside must say where it puts the event
1466
3076
  // name. Without it the receiver has nothing to dispatch on, and the failure
1467
3077
  // is a request accepted and dropped rather than an error.
1468
- if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
3078
+ if( implemented( manifest.hooks, 'inbound.event' ) && ! manifest.inbound?.headers?.event ){
1469
3079
 
1470
- throw new Error( manifest.slug + ' supports inbound.event but declares no inbound.headers.event' );
3080
+ throw new Error( manifest.slug + ' implements inbound.event but declares no inbound.headers.event' );
1471
3081
 
1472
3082
  }
1473
3083
 
1474
- if( manifest.supports?.[ 'inbound.verify' ] && ! manifest.inbound?.headers?.signature ){
3084
+ if( implemented( manifest.hooks, 'inbound.verify' ) && ! manifest.inbound?.headers?.signature ){
1475
3085
 
1476
- throw new Error( manifest.slug + ' supports inbound.verify but declares no inbound.headers.signature' );
3086
+ throw new Error( manifest.slug + ' implements inbound.verify but declares no inbound.headers.signature' );
1477
3087
 
1478
3088
  }
1479
3089
 
@@ -1492,101 +3102,125 @@ const build = ( manifest ) => {
1492
3102
  // Required on every manifest for the same reason `supports` is: a missing
1493
3103
  // answer is indistinguishable from a vendor written before the question
1494
3104
  // existed.
1495
- if( typeof manifest?.incomplete !== 'function' ){
3105
+ if( typeof manifest?.status !== 'function' ){
1496
3106
 
1497
- throw new Error( manifest.slug + ' must declare incomplete( data ) — return null when the connection is usable, or the reason it is not' );
3107
+ throw new Error( manifest.slug + ' must declare status( data ) — return null to accept the connection\'s own status, or { message, status } to override it' );
1498
3108
 
1499
3109
  }
1500
3110
 
1501
- // How to get the credential, in the merchant's words. A connection page that
1502
- // cannot say where to find an API key sends somebody to a vendor's docs
1503
- // written for a different integration.
1504
- if( ! Array.isArray( manifest?.setup ) || ! manifest.setup.length ){
3111
+ // How to connect, in the merchant's words. A connection page that cannot say
3112
+ // where to find an API key sends somebody to a vendor's docs written for a
3113
+ // different integration.
3114
+ if( ! Array.isArray( manifest?.content?.guide ) || ! manifest.content.guide.length ){
1505
3115
 
1506
- throw new Error( manifest.slug + ' needs a setup guide — an array of steps for its page' );
3116
+ throw new Error( manifest.slug + ' needs content.guide — an array of steps for its page' );
1507
3117
 
1508
3118
  }
1509
3119
 
1510
- // HOOKS A MANIFEST CAN CARRY ITSELF.
1511
- //
1512
- // Not every hook can live here a step handler needs a queue, a controller
1513
- // and vendor SDKs, and putting those behind a published package makes every
1514
- // consumer carry them. But the hooks that are pure or plain HTTP can, and
1515
- // auth.connect is the one that matters most: it is the only vendor-specific
1516
- // step in an OAuth callback, so putting it here is what lets ONE route serve
1517
- // every OAuth vendor instead of one route each.
1518
- //
1519
- // Keyed flat ('auth.connect') to match the vocabulary and the supports map,
1520
- // so the three cannot drift apart in naming.
1521
- for( const [ name, hook ] of Object.entries( manifest.hooks || {} ) ){
1522
-
1523
- if( ! HOOK_NAMES.includes( name ) ){
3120
+ // A STATUS THE SCHEMA WILL ACCEPT. Called with nothing, which is the catalog
3121
+ // case — a vendor nobody has connected — so undefined is a legitimate answer.
3122
+ // Anything else must be one of the four, because a typo here surfaces as a
3123
+ // Mongo "Document failed validation" with no clue which field caused it.
3124
+ if( typeof manifest?.status !== 'function' ){
1524
3125
 
1525
- throw new Error( manifest.slug + ' implements an unknown hook: ' + name );
3126
+ throw new Error( manifest.slug + ' must declare status( data ) — one of ' + STATUSES.join( ', ' ) );
1526
3127
 
1527
- }
3128
+ }
1528
3129
 
1529
- if( typeof hook !== 'function' ){
3130
+ const status = manifest.status({});
1530
3131
 
1531
- throw new Error( manifest.slug + ' declares hook ' + name + ' but it is not a function' );
3132
+ if( status != null && ! STATUSES.includes( status ) ){
1532
3133
 
1533
- }
3134
+ throw new Error( manifest.slug + ' status() returned ' + status + ' — must be one of ' + STATUSES.join( ', ' ) );
1534
3135
 
1535
- // Implementing a hook you declared unsupported is the support matrix
1536
- // lying, and the matrix is what a caller reads to decide whether to
1537
- // bother asking. Caught here rather than discovered as a hook that is
1538
- // never called.
1539
- if( manifest.supports?.[ name ] !== true ){
3136
+ }
1540
3137
 
1541
- throw new Error( manifest.slug + ' implements ' + name + ' but declares supports[ \'' + name + '\' ] false' );
3138
+ // THE UNIFORM SURFACE every connection answers every hook in the vocabulary,
3139
+ // and the ANSWER IS THE VALUE. There is no `supports` map any more: it was a
3140
+ // second place to say the same thing, so a manifest could implement a hook it
3141
+ // declared unsupported, or claim one it had since deleted, and build() had two
3142
+ // separate checks trying to keep them honest.
3143
+ //
3144
+ // false declined — a decision recorded, not silence
3145
+ // function supported, and the body is right here
3146
+ // {} supported, implemented in the repo holding the dependencies
3147
+ //
3148
+ // A missing entry is rejected rather than defaulted, because a default is the
3149
+ // silence this exists to remove: it would be impossible to tell a vendor that
3150
+ // declined a hook from one written before the hook existed.
3151
+ for( const [ domain, verbs ] of Object.entries( HOOKS ) ){
1542
3152
 
1543
- }
3153
+ for( const verb of verbs ){
1544
3154
 
1545
- }
3155
+ const hook = manifest.hooks?.[ domain ]?.[ verb ];
1546
3156
 
1547
- // THE UNIFORM SURFACE. Every connection answers every hook in the vocabulary,
1548
- // with true where it implements one and false where it deliberately does not.
1549
- // A missing entry is rejected rather than defaulted, because a default is
1550
- // exactly the silence this exists to remove: it would be impossible to tell a
1551
- // vendor that declined a hook from one written before the hook existed.
1552
- const supports = manifest.supports || {};
3157
+ // A domain answered wholesale `inbound : false` rather than four
3158
+ // falses is how a vendor that receives nothing says so once.
3159
+ if( manifest.hooks?.[ domain ] === false ) continue;
1553
3160
 
1554
- for( const name of HOOK_NAMES ){
3161
+ if( hook !== false && ! implemented( { [ domain ] : { [ verb ] : hook } }, domain + '.' + verb ) ){
1555
3162
 
1556
- if( typeof supports[ name ] !== 'boolean' ){
3163
+ throw new Error( manifest.slug + ' must answer hooks.' + domain + '.' + verb + ' — false, a function, or {} if another repo implements it' );
1557
3164
 
1558
- throw new Error( manifest.slug + ' must declare supports[ \'' + name + '\' ] as true or false' );
3165
+ }
1559
3166
 
1560
3167
  }
1561
3168
 
1562
3169
  }
1563
3170
 
1564
- for( const name of Object.keys( supports ) ){
3171
+ for( const [ domain, verbs ] of Object.entries( manifest.hooks || {} ) ){
3172
+
3173
+ if( ! HOOKS[ domain ] ) throw new Error( manifest.slug + ' implements an unknown hook domain: ' + domain );
3174
+
3175
+ for( const verb of Object.keys( verbs === false ? {} : verbs ) ){
3176
+
3177
+ if( ! HOOKS[ domain ].includes( verb ) ){
1565
3178
 
1566
- if( ! HOOK_NAMES.includes( name ) ){
3179
+ throw new Error( manifest.slug + ' implements an unknown hook: ' + domain + '.' + verb );
1567
3180
 
1568
- throw new Error( manifest.slug + ' declares an unknown hook: ' + name );
3181
+ }
1569
3182
 
1570
3183
  }
1571
3184
 
1572
3185
  }
1573
3186
 
1574
- for( const [ type, step ] of Object.entries( manifest.steps || {} ) ){
3187
+ // STEPS ARE NESTED FOR READABILITY and flattened to their stored name. Each
3188
+ // leaf is a FUNCTION — a step can depend on what the merchant's connection
3189
+ // knows, which a static object would have to be true for at publish time.
3190
+ for( const [ name, step ] of leaves( manifest.steps ) ){
3191
+
3192
+ const type = 'step.' + name;
1575
3193
 
1576
- if( ! type.startsWith( 'step.' ) ){
3194
+ // A step nobody added to the vocabulary is a workflow document the database
3195
+ // will refuse — and refuse naming no field. Said here instead, at import,
3196
+ // where the file that caused it is obvious.
3197
+ if( ! STEPS[ name ] ){
1577
3198
 
1578
- throw new Error( manifest.slug + ' declares a step type that is not step.<domain>.<verb>: ' + type );
3199
+ throw new Error( manifest.slug + ' declares an unknown step: ' + type + ' add it to STEPS in contract.js' );
1579
3200
 
1580
3201
  }
1581
3202
 
1582
- if( ! step?.key ) throw new Error( manifest.slug + ' step ' + type + ' needs a key — the label the builder shows' );
3203
+ // Called with nothing, which is what a step must tolerate: the builder lists
3204
+ // steps for a merchant who has not chosen a connection yet.
3205
+ const declared = step({});
1583
3206
 
1584
- if( ! QUEUES.includes( step?.queue ) ){
3207
+ if( ! declared?.key ) throw new Error( manifest.slug + ' step ' + type + ' needs a key — the label the builder shows' );
3208
+
3209
+ if( ! QUEUES.includes( declared?.queue ) ){
1585
3210
 
1586
3211
  throw new Error( manifest.slug + ' step ' + type + ' needs a queue — one of ' + QUEUES.join( ', ' ) );
1587
3212
 
1588
3213
  }
1589
3214
 
3215
+ // A step pointing at a hook this vendor does not implement is a workflow
3216
+ // that accepts the step and then silently does nothing. System steps carry
3217
+ // no hook: drawbridge-sync dispatches them and owns their handlers.
3218
+ if( declared.hook && ! implemented( manifest.hooks, declared.hook ) ){
3219
+
3220
+ throw new Error( manifest.slug + ' step ' + type + ' points at hook ' + declared.hook + ', which this vendor does not implement' );
3221
+
3222
+ }
3223
+
1590
3224
  }
1591
3225
 
1592
3226
  return Object.freeze({
@@ -1594,15 +3228,29 @@ const build = ( manifest ) => {
1594
3228
  fields : Object.freeze( manifest.fields || [] ),
1595
3229
  hooks : Object.freeze( manifest.hooks || {} ),
1596
3230
  inbound : Object.freeze( manifest.inbound || {} ),
1597
- setup : Object.freeze( manifest.setup || [] ),
1598
- supports : Object.freeze( supports ),
1599
3231
  requires : Object.freeze( manifest.requires || [] ),
1600
3232
  steps : Object.freeze( manifest.steps || {} )
1601
3233
  });
1602
3234
 
1603
3235
  };
1604
3236
 
3237
+ // EVERY STEP TYPE ANY VENDOR IMPLEMENTS, as { 'step.domain.verb' : label }.
3238
+ //
3239
+ // drawbridge-api's enums.step.type — the $jsonSchema validator on the workflow
3240
+ // collection — unions this over its own map, so adding a vendor step is a
3241
+ // manifest change and nothing else. It UNIONS rather than replaces: retired and
3242
+ // not-yet-migrated types must stay writable, or the migration that retires them
3243
+ // cannot write either.
3244
+ const stepLabels = ( catalog = connections ) => Object.fromEntries(
3245
+ Object.values( catalog ).flatMap(
3246
+ ( vendor ) => leaves( vendor.steps ).map( ( [ name ] ) => [ 'step.' + name, STEP_LABELS[ 'step.' + name ] ] )
3247
+ )
3248
+ );
3249
+
1605
3250
  const connections = Object.freeze({
3251
+ attentive : build( attentive ),
3252
+ drawbridge : build( drawbridge ),
3253
+ hubspot : build( hubspot ),
1606
3254
  klaviyo : build( klaviyo ),
1607
3255
  mailchimp : build( mailchimp ),
1608
3256
  shopify : build( shopify ),
@@ -1627,7 +3275,10 @@ const connections = Object.freeze({
1627
3275
 
1628
3276
  for( const [ slug, manifest ] of Object.entries( connections ) ){
1629
3277
 
1630
- for( const type of Object.keys( manifest.steps ) ){
3278
+ for( const [ name ] of leaves( manifest.steps ) ){
3279
+
3280
+ const type = 'step.' + name;
3281
+
1631
3282
 
1632
3283
  if( owners[ type ] ){
1633
3284
 
@@ -1653,6 +3304,21 @@ const availableConnections = ( env = {} ) => Object.fromEntries(
1653
3304
  )
1654
3305
  );
1655
3306
 
3307
+ // WHAT A MERCHANT CAN SEE AND CONNECT. Everything available, minus the private
3308
+ // ones.
3309
+ //
3310
+ // The two lists are different questions and both are needed: `drawbridge` is
3311
+ // runnable everywhere and contributes the base workflow steps, so
3312
+ // availableConnections MUST include it — but it is not a card, has no credential
3313
+ // and nothing to connect, so the connections page must not offer it.
3314
+ //
3315
+ // Splitting them rather than filtering at the call site because there are two
3316
+ // listing sites in route/organization-connection.js, and a filter applied to one
3317
+ // and forgotten on the other is exactly how a private connection surfaces.
3318
+ const catalogConnections = ( env = {} ) => Object.fromEntries(
3319
+ Object.entries( availableConnections( env ) ).filter( ( [ , manifest ] ) => ! manifest.private )
3320
+ );
3321
+
1656
3322
  // Per-slug allowlist of stored setting keys safe to return in an API response.
1657
3323
  // DERIVED from each vendor's own field declaration — a field is public unless it
1658
3324
  // says `redact : true`. An unknown slug gets an empty allowlist, so a leftover
@@ -1667,16 +3333,23 @@ const publicSettingsBySlug = Object.fromEntries(
1667
3333
  // Every step every configured vendor contributes, flattened and stamped with the
1668
3334
  // slug that owns it. The api builds its workflow catalog from this and sync
1669
3335
  // builds its routing table from it, so a step cannot exist on one side only.
3336
+ // SHAPE PRESERVED DELIBERATELY: still { ...step, slug, type }. drawbridge-sync
3337
+ // derives its whole routing table from this and its step-handler coverage test
3338
+ // reads it, so nesting the manifests must not change what comes out — otherwise
3339
+ // a manifest refactor becomes a sync release.
3340
+ //
3341
+ // Each step is now a FUNCTION, called here with no connection: this is the
3342
+ // catalog view, the same one the builder shows before a merchant picks anything.
1670
3343
  const connectionSteps = ( env = {} ) => Object.entries( availableConnections( env ) )
1671
- .flatMap( ( [ slug, manifest ] ) => Object.entries( manifest.steps )
1672
- .map( ( [ type, step ] ) => ({ ...step, slug, type }) )
3344
+ .flatMap( ( [ slug, manifest ] ) => leaves( manifest.steps )
3345
+ .map( ( [ name, step ] ) => ({ ...step({}), slug, type : 'step.' + name }) )
1673
3346
  );
1674
3347
 
1675
3348
  // Which vendors implement a given hook. The uniform surface makes this total —
1676
3349
  // every vendor appears in exactly one of the two lists, never neither.
1677
3350
  const hookSupport = ( name ) => ({
1678
- no : Object.keys( connections ).filter( ( slug ) => ! connections[ slug ].supports[ name ] ),
1679
- yes : Object.keys( connections ).filter( ( slug ) => connections[ slug ].supports[ name ] )
3351
+ no : Object.keys( connections ).filter( ( slug ) => ! implemented( connections[ slug ].hooks, name ) ),
3352
+ yes : Object.keys( connections ).filter( ( slug ) => implemented( connections[ slug ].hooks, name ) )
1680
3353
  });
1681
3354
 
1682
3355
  // A vendor's fields, described for the dashboard. Served so one generic form
@@ -1691,8 +3364,9 @@ const hookSupport = ( name ) => ({
1691
3364
  // VALUE is withheld independently by whatever redacts stored settings, and a
1692
3365
  // test asserts no descriptor ever carries one.
1693
3366
  const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
1694
- .map( ({ copy, from, input, key, label, message, options, placeholder, redact, required }) => ({
3367
+ .map( ({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
1695
3368
  ...( copy && { copy : true }),
3369
+ ...( hook && { hook }),
1696
3370
  ...( from && { from }),
1697
3371
  ...( input && { input }),
1698
3372
  key,
@@ -1700,6 +3374,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
1700
3374
  ...( message && { message }),
1701
3375
  ...( options && { options }),
1702
3376
  ...( placeholder && { placeholder }),
3377
+ // Declared false only where the vendor cannot filter, so a picker does not
3378
+ // offer a search box that quietly searches one page.
3379
+ ...( search === false && { search : false }),
1703
3380
  required : Boolean( required ),
1704
3381
  // A UI hint, not a leak: the form uses it to stop requiring the field once
1705
3382
  // the connection exists, and to say "leave blank to keep" — because the GET
@@ -1722,9 +3399,15 @@ const runHook = async ( slug, name, args = {} ) => {
1722
3399
 
1723
3400
  if( ! manifest ) return { outcome : OUTCOMES.unsupported, reason : 'no such connection: ' + slug };
1724
3401
 
1725
- if( ! manifest.supports?.[ name ] ) return { outcome : OUTCOMES.unsupported, reason : slug + ' does not implement ' + name };
3402
+ // The hook's own value is the answer there is no `supports` map to consult,
3403
+ // and therefore none to disagree with what is actually here.
3404
+ const hook = name.split( '.' ).reduce( ( node, key ) => node?.[ key ], manifest.hooks );
3405
+
3406
+ if( hook === false || hook == null ){
3407
+
3408
+ return { outcome : OUTCOMES.unsupported, reason : slug + ' does not implement ' + name };
1726
3409
 
1727
- const hook = manifest.hooks?.[ name ];
3410
+ }
1728
3411
 
1729
3412
  // Declared supported, implemented somewhere else. Sync owns the step handlers
1730
3413
  // and lifecycle jobs, so this is a legitimate answer here rather than a fault
@@ -1737,7 +3420,12 @@ const runHook = async ( slug, name, args = {} ) => {
1737
3420
 
1738
3421
  try {
1739
3422
 
1740
- return { outcome : OUTCOMES.answered, result : await hook( args ) };
3423
+ // THE MANIFEST RIDES ALONG so a hook can read its own declared urls rather
3424
+ // than repeating them as literals — `revoke` was a literal in klaviyo's
3425
+ // disconnect until auth.oauth.urls gathered every vendor address in one
3426
+ // place, and a hook that cannot see its own manifest would have forced it
3427
+ // back. Callers never have to know to pass it.
3428
+ return { outcome : OUTCOMES.answered, result : await hook({ ...args, manifest }) };
1741
3429
 
1742
3430
  } catch ( error ) {
1743
3431
 
@@ -1756,6 +3444,39 @@ const stepQueues = ( env = {} ) => Object.fromEntries(
1756
3444
  connectionSteps( env ).map( ( step ) => [ step.type, step.queue ] )
1757
3445
  );
1758
3446
 
3447
+ // THE WHOLE ROUTING TABLE: live step types plus the retired ones still stored
3448
+ // on workflow documents.
3449
+ //
3450
+ // Both halves were already facts here — stepQueues answers new→queue and
3451
+ // RETIRED answers old→new — but the composition of the two lived in sync as a
3452
+ // hand-written block of `step.shopify.* : 'connection'` literals. That block
3453
+ // and this file had to be edited together and nothing said so, which is the
3454
+ // exact shape of the drift that orphans a queue: retire a step type here,
3455
+ // forget the literal there, and the dispatcher logs unknown-step-type while
3456
+ // the work silently stops.
3457
+ //
3458
+ // A retired type routes wherever its REPLACEMENT routes, because that is what
3459
+ // retiring meant — same work, new name. So there is no second table to keep;
3460
+ // moving a live step to a different queue moves its aliases with it.
3461
+ //
3462
+ // Retired entries whose replacement is not currently live (a vendor gated off
3463
+ // by env) are omitted rather than guessed: no queue exists to route them to,
3464
+ // and a made-up destination is worse than a clean unknown-step-type.
3465
+ const stepRoutes = ( env = {} ) => {
3466
+
3467
+ const live = stepQueues( env );
3468
+
3469
+ return Object.freeze({
3470
+ ...live,
3471
+ ...Object.fromEntries(
3472
+ Object.entries( RETIRED )
3473
+ .filter( ( [ , replacement ] ) => live[ replacement ] )
3474
+ .map( ( [ retired, replacement ] ) => [ retired, live[ replacement ] ] )
3475
+ )
3476
+ });
3477
+
3478
+ };
3479
+
1759
3480
  // Merchant-facing scope-drift copy.
1760
3481
  //
1761
3482
  // THIS IS A CROSS-REPO CONTRACT. drawbridge-sync writes it onto the connection
@@ -1813,22 +3534,27 @@ const redactSettings = ({ slug, settings }) => {
1813
3534
  // dropped rather than carried forward as keys that look supported.
1814
3535
  const publicConnectionKeys = Object.freeze([
1815
3536
  'actions',
1816
- 'category',
1817
- 'confirm',
3537
+ // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
3538
+ // auth.type, content.redirect and the manifest's manage() — the client reads
3539
+ // connect.type to choose entered-vs-installed, connect.redirect for the App
3540
+ // Store link, connect.manage for the admin deep link. It was dropped from
3541
+ // this list when the manifests stopped declaring it, which stripped the
3542
+ // composed object from every response and broke all three.
1818
3543
  'connect',
3544
+ // EVERYTHING A MERCHANT READS, in one key: confirm, description, errors,
3545
+ // excerpt, guide, and any vendor redirect copy.
3546
+ 'content',
1819
3547
  'createdAt',
3548
+ // The connection DOCUMENT's own errors array — scope-drift entries written by
3549
+ // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
3550
+ // the document is spread OVER the resolved manifest downstream, so the two
3551
+ // sharing this key means the array silently wins.
1820
3552
  'errors',
1821
- 'description',
1822
- 'excerpt',
1823
3553
  'fields',
1824
3554
  'group',
1825
3555
  'id',
1826
3556
  'image',
1827
- // The reason a connected vendor still is not usable — a Klaviyo grant with no
1828
- // list chosen. Public because the card that shows Pending has to say why.
1829
- 'incomplete',
1830
- 'label',
1831
- 'setup',
3557
+
1832
3558
  'settings',
1833
3559
  'shop',
1834
3560
  'slug',
@@ -1868,7 +3594,12 @@ const projectConnection = ( record ) => {
1868
3594
  // never reach the output under that name: the connection DOCUMENT's own
1869
3595
  // `settings` is spread over this downstream, and two different things sharing a
1870
3596
  // key is how a redaction quietly stops applying.
1871
- const resolveConnection = ( item, data ) => {
3597
+ // `env` reaches a function field as its SECOND argument, so a manifest can name
3598
+ // a deployment's variables without reading process.env itself — the same reason
3599
+ // auth.oauth.client names them rather than holding them. A published package
3600
+ // that reads its consumer's environment is one that behaves differently
3601
+ // depending on who imported it.
3602
+ const resolveConnection = ( item, data, env = {} ) => {
1872
3603
 
1873
3604
  if( ! item ) return item;
1874
3605
 
@@ -1877,10 +3608,10 @@ const resolveConnection = ( item, data ) => {
1877
3608
  .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
1878
3609
  .map( ( [ key, value ] ) => [
1879
3610
  key,
1880
- ( typeof value === 'function' ? value( data ) : value )
3611
+ ( typeof value === 'function' ? value( data, env ) : value )
1881
3612
  ] )
1882
3613
  );
1883
3614
 
1884
3615
  };
1885
3616
 
1886
- export { AUTH_TYPES, CATEGORIES, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, accessToken, availableConnections, build, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, refresh, resolveConnection, runHook, scopesMessage, stepQueues, tokenSettings };
3617
+ export { AUTH_TYPES, GROUPS, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };