@drawbridge/drawbridge-utils 0.0.111 → 0.0.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +2286 -578
- package/dist/connections/index.d.cts +2012 -571
- package/dist/connections/index.d.ts +2012 -571
- package/dist/connections/index.js +2265 -572
- package/dist/connections/oauth.cjs +49 -50
- package/dist/connections/oauth.d.cts +83 -70
- package/dist/connections/oauth.d.ts +83 -70
- package/dist/connections/oauth.js +47 -47
- package/dist/safe-http.d.cts +1 -1
- package/dist/safe-http.d.ts +1 -1
- package/dist/sendgrid.cjs +168 -0
- package/dist/sendgrid.d.cts +185 -0
- package/dist/sendgrid.d.ts +185 -0
- package/dist/sendgrid.js +140 -0
- package/dist/twilio.cjs +112 -0
- package/dist/twilio.d.cts +64 -0
- package/dist/twilio.d.ts +64 -0
- package/dist/twilio.js +86 -0
- package/package.json +11 -1
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { consentUrl,
|
|
3
|
-
import {
|
|
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,10 +129,89 @@ const HOOKS = Object.freeze({
|
|
|
94
129
|
'process'
|
|
95
130
|
]),
|
|
96
131
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
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.
|
|
195
|
+
//
|
|
196
|
+
// ONE SHAPE FOR ALL OF THEM — searchable and cursor-paged:
|
|
197
|
+
//
|
|
198
|
+
// ({ connection, cursor, limit, search, settings, token })
|
|
199
|
+
// -> { items : [ { id, title, ... } ], pageInfo : { endCursor, hasNextPage } }
|
|
200
|
+
//
|
|
201
|
+
// Lifted from what the Shopify product picker already does, rather than
|
|
202
|
+
// invented: that endpoint takes cursor/limit/search and returns items plus a
|
|
203
|
+
// pageInfo, and ListResource consumes exactly that. It was a good contract
|
|
204
|
+
// written once for one vendor; this makes it the contract.
|
|
205
|
+
//
|
|
206
|
+
// `title` is not arbitrary — ListResource searches `[ 'title' ]` by default,
|
|
207
|
+
// so normalising to { id, title } is what lets a picker work with no
|
|
208
|
+
// per-vendor configuration.
|
|
209
|
+
//
|
|
210
|
+
// HOW a vendor searches is its own business, which is the point of a hook.
|
|
211
|
+
// Shopify pushes the term into its GraphQL query, Klaviyo has a filter
|
|
212
|
+
// parameter, and Mailchimp's /lists has no name filter at all so its hook
|
|
213
|
+
// matches against what it fetched. The caller never learns which.
|
|
214
|
+
resources : Object.freeze([
|
|
101
215
|
// The named groups a contact can be synced INTO. Klaviyo calls them lists,
|
|
102
216
|
// Mailchimp calls them audiences; `audiences` is the industry-generic term
|
|
103
217
|
// and belongs to neither vendor's API. Read at form time, so a merchant
|
|
@@ -114,6 +228,78 @@ const HOOKS = Object.freeze({
|
|
|
114
228
|
|
|
115
229
|
});
|
|
116
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
|
+
|
|
117
303
|
// Flat 'domain.verb' names, which is how a manifest declares support and how a
|
|
118
304
|
// caller asks for one.
|
|
119
305
|
const HOOK_NAMES = Object.freeze(
|
|
@@ -135,6 +321,22 @@ const OUTCOMES = Object.freeze({
|
|
|
135
321
|
unsupported : 'unsupported'
|
|
136
322
|
});
|
|
137
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
|
+
|
|
138
340
|
// HOW A VENDOR IS CONNECTED. Four kinds, because the merchant's experience of
|
|
139
341
|
// each is genuinely different and the dashboard renders from this:
|
|
140
342
|
//
|
|
@@ -147,12 +349,17 @@ const OUTCOMES = Object.freeze({
|
|
|
147
349
|
// merchant never sees a consent screen we sent them to — they start at the
|
|
148
350
|
// vendor's app store — and a dashboard that offers a Connect button for one is
|
|
149
351
|
// lying about the other.
|
|
150
|
-
|
|
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' ]);
|
|
151
355
|
|
|
152
356
|
// WHAT KIND OF VENDOR THIS IS. Closed, because a card list that works at three
|
|
153
|
-
// connections stops working the day every CMS joins it, and a
|
|
357
|
+
// connections stops working the day every CMS joins it, and a group nobody
|
|
154
358
|
// spelled right is a filter chip that silently shows nothing.
|
|
155
|
-
|
|
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' ]);
|
|
156
363
|
|
|
157
364
|
// WHAT AN OAUTH VENDOR MUST DECLARE, so one implementation serves all of them
|
|
158
365
|
// rather than one module per vendor.
|
|
@@ -168,7 +375,11 @@ const CATEGORIES = Object.freeze([ 'commerce', 'contacts', 'developer', 'messagi
|
|
|
168
375
|
// params vendor quirks (Google wants access_type=offline)
|
|
169
376
|
// pkce Klaviyo's OAuth 2.1 REQUIRES a code_verifier/code_challenge pair;
|
|
170
377
|
// most vendors do not. A flag rather than a Klaviyo module.
|
|
171
|
-
|
|
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' ]);
|
|
172
383
|
|
|
173
384
|
// HOW A FIELD IS EDITED. Closed, because the dashboard renders one form for
|
|
174
385
|
// every connection and it can only render what it has a component for. An open
|
|
@@ -288,7 +499,11 @@ const accessToken = async ({
|
|
|
288
499
|
|
|
289
500
|
}
|
|
290
501
|
|
|
291
|
-
|
|
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({
|
|
292
507
|
clientId,
|
|
293
508
|
clientSecret,
|
|
294
509
|
descriptor : manifest.auth.oauth,
|
|
@@ -304,33 +519,601 @@ const accessToken = async ({
|
|
|
304
519
|
|
|
305
520
|
};
|
|
306
521
|
|
|
307
|
-
//
|
|
522
|
+
// Drawbridge, exported from the brand kit and left as authored — the fills are the
|
|
308
523
|
// vendor's own mark, not a recolour.
|
|
309
524
|
//
|
|
310
525
|
// A .js wrapper around otherwise untouched SVG so `node --test` can run against
|
|
311
526
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
312
527
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
313
528
|
var icon$3 = `<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="#BAEC5F"/>
|
|
530
|
+
<g clip-path="url(#clip0_2115_2832)">
|
|
531
|
+
<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"/>
|
|
532
|
+
<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"/>
|
|
533
|
+
</g>
|
|
534
|
+
<defs>
|
|
535
|
+
<clipPath id="clip0_2115_2832">
|
|
536
|
+
<rect width="220" height="284" fill="white" transform="translate(140 108)"/>
|
|
537
|
+
</clipPath>
|
|
538
|
+
</defs>
|
|
539
|
+
</svg>`;
|
|
540
|
+
|
|
541
|
+
// Drawbridge itself — the PRIVATE connection.
|
|
542
|
+
//
|
|
543
|
+
// Nobody connects this. There is no credential, no consent, and no card on the
|
|
544
|
+
// connections page. It exists because a workflow step points at a hook, and the
|
|
545
|
+
// six most common steps — sending an email, alerting a member, syncing a segment
|
|
546
|
+
// — are things Drawbridge does rather than things a vendor does.
|
|
547
|
+
//
|
|
548
|
+
// Before this, those steps were the exception: every other step resolved through
|
|
549
|
+
// a manifest and these resolved through a hardcoded registry in drawbridge-sync.
|
|
550
|
+
// One exception is all it takes for "every step is a declaration pointing at a
|
|
551
|
+
// hook" to stop being a rule somebody can rely on.
|
|
552
|
+
//
|
|
553
|
+
// PRIVATE is a real distinction, not a flag to hide a card. A public connection
|
|
554
|
+
// answers "can this merchant connect it" through `requires` and a plan feature.
|
|
555
|
+
// A private one is always present, in every deployment, for every organization —
|
|
556
|
+
// so it declares neither, and build() knows not to ask.
|
|
557
|
+
var drawbridge = {
|
|
558
|
+
auth : {
|
|
559
|
+
type : 'none'
|
|
560
|
+
},
|
|
561
|
+
content : {
|
|
562
|
+
confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
|
|
563
|
+
description : [
|
|
564
|
+
'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.'
|
|
565
|
+
],
|
|
566
|
+
excerpt : 'The steps Drawbridge runs itself.',
|
|
567
|
+
guide : [
|
|
568
|
+
'Nothing to do. These steps are available in every workflow builder.'
|
|
569
|
+
]
|
|
570
|
+
},
|
|
571
|
+
exclusive : false,
|
|
572
|
+
fields : [],
|
|
573
|
+
group : 'developer',
|
|
574
|
+
// EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
|
|
575
|
+
// suppression collection and the queues; segment sync needs the streams. A
|
|
576
|
+
// published package carrying those makes every consumer carry them, which is
|
|
577
|
+
// the reason `{}` exists as an answer.
|
|
578
|
+
hooks : {
|
|
579
|
+
auth : {
|
|
580
|
+
// Nothing to connect, revoke, probe or re-scope.
|
|
581
|
+
connect : false,
|
|
582
|
+
disconnect : false,
|
|
583
|
+
probe : false,
|
|
584
|
+
scopes : false,
|
|
585
|
+
token : false
|
|
586
|
+
},
|
|
587
|
+
commerce : false,
|
|
588
|
+
contacts : { remove : false, sync : false },
|
|
589
|
+
email : {
|
|
590
|
+
digest : {},
|
|
591
|
+
// To organization members. NEVER suppressed and never billed: an
|
|
592
|
+
// entrant's opt-out must not silence an alert to staff, and staff mail is
|
|
593
|
+
// not a metered send.
|
|
594
|
+
notify : {},
|
|
595
|
+
// To a lead. Suppression applies and the send is billed.
|
|
596
|
+
send : {}
|
|
597
|
+
},
|
|
598
|
+
inbound : false,
|
|
599
|
+
lifecycle : false,
|
|
600
|
+
resources : {
|
|
601
|
+
audiences : false,
|
|
602
|
+
prices : false,
|
|
603
|
+
products : false,
|
|
604
|
+
promotions : false
|
|
605
|
+
},
|
|
606
|
+
segment : { sync : {} },
|
|
607
|
+
sms : { send : {} },
|
|
608
|
+
webhook : false
|
|
609
|
+
},
|
|
610
|
+
icon: icon$3,
|
|
611
|
+
// PRIVATE: never in the catalog, always available to the builder.
|
|
612
|
+
private : true,
|
|
613
|
+
requires : [],
|
|
614
|
+
slug : 'drawbridge',
|
|
615
|
+
// Always on. There is no credential that could go bad and no configuration a
|
|
616
|
+
// merchant could leave half-finished.
|
|
617
|
+
status : () => 'active',
|
|
618
|
+
// DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
|
|
619
|
+
// below — trigger, billable, settings — is what that catalog and the workflow
|
|
620
|
+
// route already enforce today, because this replaces them rather than
|
|
621
|
+
// competing with them.
|
|
622
|
+
//
|
|
623
|
+
// NOT HERE, deliberately:
|
|
624
|
+
//
|
|
625
|
+
// step.segment.sync a SYSTEM step, dispatched by drawbridge-sync rather
|
|
626
|
+
// than offered in the builder. It fans out, so the shell
|
|
627
|
+
// opens its step document and the chunks close it.
|
|
628
|
+
steps : {
|
|
629
|
+
|
|
630
|
+
email : {
|
|
631
|
+
|
|
632
|
+
// SCHEDULE-TRIGGERED, not lead-triggered: it is offered under Daily,
|
|
633
|
+
// Weekly and Monthly. Those triggers had offered no steps at all, so a
|
|
634
|
+
// scheduled workflow was selectable and inert until this landed.
|
|
635
|
+
digest : () => ({
|
|
636
|
+
hook : 'email.digest',
|
|
637
|
+
key : 'Email — Digest',
|
|
638
|
+
queue : 'notification',
|
|
639
|
+
settings : {
|
|
640
|
+
// The organization OWNER is always a recipient, resolved in sync,
|
|
641
|
+
// so this is additional recipients rather than the list. It cannot
|
|
642
|
+
// be required: the members endpoint is owner-gated and the owner is
|
|
643
|
+
// not a member document, so a solo merchant has nothing to pick and
|
|
644
|
+
// could never save the step.
|
|
645
|
+
members : { of : 'string', type : 'array' },
|
|
646
|
+
message : { required : true, type : 'string' },
|
|
647
|
+
subject : { required : true, type : 'string' }
|
|
648
|
+
},
|
|
649
|
+
triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
|
|
650
|
+
usage : { actions : 0 }
|
|
651
|
+
}),
|
|
652
|
+
|
|
653
|
+
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
654
|
+
// not silence an alert to staff — and not billed.
|
|
655
|
+
notify : () => ({
|
|
656
|
+
hook : 'email.notify',
|
|
657
|
+
key : 'Email — Notification',
|
|
658
|
+
queue : 'notification',
|
|
659
|
+
settings : {
|
|
660
|
+
members : { of : 'string', type : 'array' },
|
|
661
|
+
message : { required : true, type : 'string' },
|
|
662
|
+
subject : { required : true, type : 'string' }
|
|
663
|
+
},
|
|
664
|
+
triggers : [ 'lead.insert' ],
|
|
665
|
+
// Zero is a PRICE, and a deliberate one. Declared rather than omitted
|
|
666
|
+
// so "this is free" and "nobody decided" stay different statements;
|
|
667
|
+
// completeStep bills only when actions > 0.
|
|
668
|
+
usage : { actions : 0 }
|
|
669
|
+
}),
|
|
670
|
+
|
|
671
|
+
// To a LEAD. Suppression applies and the send is billed.
|
|
672
|
+
send : () => ({
|
|
673
|
+
hook : 'email.send',
|
|
674
|
+
key : 'Email — Send email',
|
|
675
|
+
queue : 'notification',
|
|
676
|
+
settings : {
|
|
677
|
+
message : { required : true, type : 'string' },
|
|
678
|
+
subject : { required : true, type : 'string' }
|
|
679
|
+
},
|
|
680
|
+
triggers : [ 'lead.insert' ],
|
|
681
|
+
// ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
|
|
682
|
+
// customer-facing number; the handler read it too, so the same fact
|
|
683
|
+
// was stated in two places and only one of them was reviewed.
|
|
684
|
+
usage : { actions : channels.email.actionsPerSend }
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
// WITHDRAWN, which is a third thing from builder and system: declared,
|
|
690
|
+
// routed and runnable, but never offered.
|
|
691
|
+
//
|
|
692
|
+
// It went when the twilio connection did — a connection-gated step with no
|
|
693
|
+
// connection to gate on could only ever render permanently disabled. Stored
|
|
694
|
+
// workflows still carry it, so it must keep running, and enums.step.type
|
|
695
|
+
// keeps it for the same reason.
|
|
696
|
+
//
|
|
697
|
+
// NO TRIGGERS is what keeps it out of the builder: the catalog derives from
|
|
698
|
+
// triggers, so a step with none is unreachable by a merchant without a
|
|
699
|
+
// second list saying so.
|
|
700
|
+
//
|
|
701
|
+
// Platform SMS returns as a base step the way email did. That is this entry
|
|
702
|
+
// gaining triggers, not a new one.
|
|
703
|
+
sms : {
|
|
704
|
+
|
|
705
|
+
send : () => ({
|
|
706
|
+
hook : 'sms.send',
|
|
707
|
+
key : 'Send an SMS',
|
|
708
|
+
queue : 'notification',
|
|
709
|
+
settings : {
|
|
710
|
+
message : { required : true, type : 'string' },
|
|
711
|
+
subject : { required : true, type : 'string' }
|
|
712
|
+
},
|
|
713
|
+
// Priced per SEGMENT and billed from the first, which the send
|
|
714
|
+
// resolves from the message length. This is the floor.
|
|
715
|
+
usage : { actions : channels.sms.actionsPerSegment },
|
|
716
|
+
withdrawn : true
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
segment : {
|
|
722
|
+
|
|
723
|
+
// FANS OUT. It evaluates every contact in the organization against every
|
|
724
|
+
// segment, which is too much for one job — so the hook returns chunks and
|
|
725
|
+
// the shell defers completion: openStep writes the document with a slot
|
|
726
|
+
// per chunk, and whichever chunk lands last closes it and resumes the
|
|
727
|
+
// chain.
|
|
728
|
+
//
|
|
729
|
+
// It carries a hook like every other step. An earlier version declared
|
|
730
|
+
// none, on the theory that fan-out was a second protocol the shell could
|
|
731
|
+
// not run; it is the same protocol with the ending deferred, and a step
|
|
732
|
+
// declaring no hook is silently SKIPPED by the runner.
|
|
733
|
+
sync : () => ({
|
|
734
|
+
description : 'Recalculates segment membership on a daily schedule.',
|
|
735
|
+
hook : 'segment.sync',
|
|
736
|
+
key : 'Segment Sync',
|
|
737
|
+
queue : 'segment',
|
|
738
|
+
system : true
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
},
|
|
744
|
+
tasks : () => [],
|
|
745
|
+
title : 'Drawbridge'
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// HubSpot — PRIVATE, and Drawbridge's own CRM rather than a merchant's.
|
|
749
|
+
//
|
|
750
|
+
// A signup's UTM properties go onto a contact in OUR portal, keyed by the
|
|
751
|
+
// `hubspotId` cached on the `user` document. There is no merchant credential and
|
|
752
|
+
// no card: one Private App token, one portal, ours.
|
|
753
|
+
//
|
|
754
|
+
// PRIVATE RATHER THAN ABSENT, so the last vendor transport outside a manifest
|
|
755
|
+
// comes inside one. Its hooks are called by drawbridge-sync's user stream rather
|
|
756
|
+
// than by a workflow step — which is not unusual: auth.* hooks are called by the
|
|
757
|
+
// OAuth runner and inbound.* by the webhooks route. A hook is a verb, and only
|
|
758
|
+
// some verbs are steps.
|
|
759
|
+
//
|
|
760
|
+
// FLIPPING IT PUBLIC LATER is a real possibility — merchants syncing their own
|
|
761
|
+
// entrants to their own portal is the Klaviyo shape exactly. When that happens it
|
|
762
|
+
// is a SECOND manifest, not this one edited: this writes Drawbridge account
|
|
763
|
+
// holders into our sales pipeline, and a merchant's entrants must never land
|
|
764
|
+
// there. `token` is already a parameter so the two can share this transport
|
|
765
|
+
// without ever sharing a credential.
|
|
766
|
+
|
|
767
|
+
const HUBSPOT_BASE = 'https://api.hubapi.com';
|
|
768
|
+
|
|
769
|
+
// Best-effort: with no Private App token configured the whole integration
|
|
770
|
+
// no-ops so a missing secret never crashes sync. Callers wrap these in
|
|
771
|
+
// try/catch and treat a missing id gracefully.
|
|
772
|
+
const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
773
|
+
|
|
774
|
+
return ( fetcher || request )({
|
|
775
|
+
body,
|
|
776
|
+
headers : {
|
|
777
|
+
'Authorization' : 'Bearer ' + ( token || process.env.HUBSPOT_ACCESS_TOKEN )
|
|
778
|
+
},
|
|
779
|
+
method,
|
|
780
|
+
query,
|
|
781
|
+
url : HUBSPOT_BASE + path
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// Custom contact properties carrying the campaign a signup arrived on
|
|
787
|
+
// (`user.utm`, written by provisionUser in the api's route/oauth.js).
|
|
788
|
+
//
|
|
789
|
+
// CUSTOM BECAUSE HUBSPOT HAS NO EQUIVALENT — verified against the portal's own
|
|
790
|
+
// property list, where nothing is named or labelled utm. See the note on
|
|
791
|
+
// CLICK_PROPERTIES below for what the analytics family can and cannot do.
|
|
792
|
+
//
|
|
793
|
+
// THESE MUST EXIST IN THE PORTAL — Settings -> Properties -> Contacts, single-line
|
|
794
|
+
// text — or HubSpot rejects the whole payload. See the retry in send() below, which
|
|
795
|
+
// is what stops a missing property from costing a new user its contact.
|
|
796
|
+
//
|
|
797
|
+
// NAMED FOR THE PARAMETER, not for us. These are the six names Google's Campaign URL
|
|
798
|
+
// Builder emits, so the property is called exactly what the link that produced it
|
|
799
|
+
// carried — utm_source holds what arrived as ?utm_source=. A drawbridge_ prefix would
|
|
800
|
+
// have made them ours to explain; this way anything that already speaks the standard,
|
|
801
|
+
// including a future import or a tool somebody adds to the portal, maps onto them with
|
|
802
|
+
// nothing to translate.
|
|
803
|
+
//
|
|
804
|
+
// The underscores are the convention's, not a slip: these are external property names
|
|
805
|
+
// matching a url parameter, which is the one place that spelling is correct.
|
|
806
|
+
//
|
|
807
|
+
// `id` matters most: drawbridge-growth stamps utm_id with the campaign slug, so it is
|
|
808
|
+
// what resolves a contact back to one campaign document. The rest describe it.
|
|
809
|
+
const UTM_PROPERTIES = {
|
|
810
|
+
campaign : 'utm_campaign',
|
|
811
|
+
content : 'utm_content',
|
|
812
|
+
id : 'utm_id',
|
|
813
|
+
medium : 'utm_medium',
|
|
814
|
+
source : 'utm_source',
|
|
815
|
+
term : 'utm_term'
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// Click ids go into HubSpot's OWN properties, which are writable and already exist in
|
|
819
|
+
// every portal — checked against ours, where modificationMetadata.readOnlyValue is
|
|
820
|
+
// false on all five. A custom property here would have been a worse copy of a field
|
|
821
|
+
// HubSpot's ads tooling already understands, and one more thing somebody has to create
|
|
822
|
+
// by hand.
|
|
823
|
+
//
|
|
824
|
+
// There is no equivalent for the utm tags. Nothing in the contact schema is named or
|
|
825
|
+
// labelled utm; the nearest family is hs_analytics_*, and it cannot hold these:
|
|
826
|
+
// hs_analytics_source is a coarse enumeration (PAID_SEARCH, SOCIAL_MEDIA...) with no
|
|
827
|
+
// room for a campaign id, and the drill-downs that DO carry campaign detail --
|
|
828
|
+
// hs_analytics_source_data_1 and _2 -- are read-only, written only by HubSpot's own
|
|
829
|
+
// tracking script. Hence the custom properties above, and only above.
|
|
830
|
+
const CLICK_PROPERTIES = {
|
|
831
|
+
fbclid : 'hs_facebook_click_id',
|
|
832
|
+
gclid : 'hs_google_click_id',
|
|
833
|
+
liFatId : 'hs_linkedin_click_id',
|
|
834
|
+
msclkid : 'hs_bing_click_id',
|
|
835
|
+
ttclid : 'hs_tiktok_click_id'
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// Which properties may not exist in the portal, and so are the ones worth dropping on
|
|
839
|
+
// a 400. The native click ids are NOT in this set: they ship with every portal, so a
|
|
840
|
+
// 400 mentioning one is a real error rather than a setup gap, and silently dropping it
|
|
841
|
+
// would hide that.
|
|
842
|
+
//
|
|
843
|
+
// Derived from the map above rather than matched on a `utm_` prefix, so the two cannot
|
|
844
|
+
// disagree — and so a property added here later is not silently made droppable by the
|
|
845
|
+
// shape of its name.
|
|
846
|
+
const DROPPABLE = new Set( Object.values( UTM_PROPERTIES ) );
|
|
847
|
+
|
|
848
|
+
const isUtmProperty = ( key ) => DROPPABLE.has( key );
|
|
849
|
+
|
|
850
|
+
// HubSpot contact properties are lowercase (email/firstname/lastname). Only
|
|
851
|
+
// send the keys we actually have so a name-less user doesn't clear fields —
|
|
852
|
+
// the same rule applies to the campaign, which most contacts will not have.
|
|
853
|
+
const toProperties = ({ email, firstName, lastName, utm }) => {
|
|
854
|
+
|
|
855
|
+
const properties = {};
|
|
856
|
+
|
|
857
|
+
if( email !== undefined ) properties.email = email;
|
|
858
|
+
if( firstName !== undefined ) properties.firstname = firstName;
|
|
859
|
+
if( lastName !== undefined ) properties.lastname = lastName;
|
|
860
|
+
|
|
861
|
+
if( utm ){
|
|
862
|
+
|
|
863
|
+
for( const [ key, property ] of Object.entries( UTM_PROPERTIES ) ){
|
|
864
|
+
|
|
865
|
+
if( utm[ key ] ) properties[ property ] = utm[ key ];
|
|
866
|
+
|
|
867
|
+
}
|
|
868
|
+
// Each click id into the property HubSpot already keeps for that platform.
|
|
869
|
+
// Only one is ever set in practice — whichever platform the click came from —
|
|
870
|
+
// so the other four simply never appear.
|
|
871
|
+
for( const [ key, property ] of Object.entries( CLICK_PROPERTIES ) ){
|
|
872
|
+
|
|
873
|
+
if( utm.click?.[ key ] ) properties[ property ] = utm.click[ key ];
|
|
874
|
+
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return properties;
|
|
878
|
+
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// One request, with a retry that drops the campaign properties.
|
|
882
|
+
//
|
|
883
|
+
// HubSpot 400s the ENTIRE request if a single property does not exist in the portal,
|
|
884
|
+
// and stream/user.js swallows the error — so without this fallback a portal missing
|
|
885
|
+
// the custom properties would silently cost every new user its hubspotId, breaking
|
|
886
|
+
// the CRM link for a reason nobody would think to look for. Losing the campaign is
|
|
887
|
+
// acceptable; losing the contact is not.
|
|
888
|
+
//
|
|
889
|
+
// Worth having even with the properties in place: somebody renaming or archiving one
|
|
890
|
+
// in the portal is a change this repo cannot see coming.
|
|
891
|
+
const send = async ({ doc, fetcher, method, path, token }) => {
|
|
892
|
+
|
|
893
|
+
const properties = toProperties( doc );
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
|
|
897
|
+
return await hubspotRequest({
|
|
898
|
+
body : { properties },
|
|
899
|
+
fetcher,
|
|
900
|
+
method,
|
|
901
|
+
path,
|
|
902
|
+
token
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
} catch ( error ) {
|
|
906
|
+
|
|
907
|
+
const enriched = Object.keys( properties ).some( isUtmProperty );
|
|
908
|
+
|
|
909
|
+
if( error?.status !== 400 || ! enriched ) throw error;
|
|
910
|
+
|
|
911
|
+
return await hubspotRequest({
|
|
912
|
+
body : {
|
|
913
|
+
properties : Object.fromEntries(
|
|
914
|
+
Object.entries( properties ).filter( ( [ key ] ) => ! isUtmProperty( key ) )
|
|
915
|
+
)
|
|
916
|
+
},
|
|
917
|
+
fetcher,
|
|
918
|
+
method,
|
|
919
|
+
path,
|
|
920
|
+
token
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// Find a contact id by email. Swallows its own errors: a search that fails must
|
|
928
|
+
// not stop the create that would follow it.
|
|
929
|
+
const lookup = async ({ email, fetcher, token }) => {
|
|
930
|
+
|
|
931
|
+
if( ! token || ! email ) return;
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
|
|
935
|
+
const body = await hubspotRequest({
|
|
936
|
+
body : {
|
|
937
|
+
filterGroups : [
|
|
938
|
+
{
|
|
939
|
+
filters : [
|
|
940
|
+
{
|
|
941
|
+
operator : 'EQ',
|
|
942
|
+
propertyName : 'email',
|
|
943
|
+
value : email
|
|
944
|
+
}
|
|
945
|
+
]
|
|
946
|
+
}
|
|
947
|
+
],
|
|
948
|
+
limit : 1,
|
|
949
|
+
properties : [ 'email' ]
|
|
950
|
+
},
|
|
951
|
+
fetcher,
|
|
952
|
+
method : 'POST',
|
|
953
|
+
path : '/crm/v3/objects/contacts/search',
|
|
954
|
+
token
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
return body?.results?.[ 0 ]?.id;
|
|
958
|
+
|
|
959
|
+
} catch ( error ){
|
|
960
|
+
|
|
961
|
+
// Deliberately swallowed.
|
|
962
|
+
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
var hubspot = {
|
|
968
|
+
auth : {
|
|
969
|
+
// A Private App token from our own portal. Nothing to connect, nothing to
|
|
970
|
+
// consent to, and no merchant involved.
|
|
971
|
+
type : 'none'
|
|
972
|
+
},
|
|
973
|
+
content : {
|
|
974
|
+
confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
|
|
975
|
+
description : [
|
|
976
|
+
'Drawbridge keeps its own HubSpot portal in step with account signups, so the campaign a customer arrived on is on their contact record.'
|
|
977
|
+
],
|
|
978
|
+
excerpt : 'Drawbridge\'s own CRM sync.',
|
|
979
|
+
guide : [
|
|
980
|
+
'Nothing to do. This is internal to Drawbridge.'
|
|
981
|
+
]
|
|
982
|
+
},
|
|
983
|
+
exclusive : false,
|
|
984
|
+
fields : [],
|
|
985
|
+
group : 'contacts',
|
|
986
|
+
hooks : {
|
|
987
|
+
auth : {
|
|
988
|
+
connect : false,
|
|
989
|
+
disconnect : false,
|
|
990
|
+
probe : false,
|
|
991
|
+
scopes : false,
|
|
992
|
+
token : false
|
|
993
|
+
},
|
|
994
|
+
commerce : false,
|
|
995
|
+
contacts : {
|
|
996
|
+
|
|
997
|
+
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
998
|
+
// to search then remove, which is one round trip it should not have to
|
|
999
|
+
// know about.
|
|
1000
|
+
remove : async ({ email, fetcher, id, token }) => {
|
|
1001
|
+
|
|
1002
|
+
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
1003
|
+
|
|
1004
|
+
if( ! key ) return;
|
|
1005
|
+
|
|
1006
|
+
const contact = id || await lookup({ email, fetcher, token : key });
|
|
1007
|
+
|
|
1008
|
+
if( ! contact ) return;
|
|
1009
|
+
|
|
1010
|
+
return hubspotRequest({
|
|
1011
|
+
fetcher,
|
|
1012
|
+
method : 'DELETE',
|
|
1013
|
+
path : '/crm/v3/objects/contacts/' + contact,
|
|
1014
|
+
token : key
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
// Connect an account to its contact by email, creating it if absent, and
|
|
1020
|
+
// return the contact id. Unlike SendGrid, HubSpot renames a contact's
|
|
1021
|
+
// email in place, so an email change is a plain PATCH on the cached id —
|
|
1022
|
+
// no delete-old-then-create-new.
|
|
1023
|
+
//
|
|
1024
|
+
// Prefer the cached hubspotId; fall back to a search; create last.
|
|
1025
|
+
sync : async ({ doc, fetcher, token }) => {
|
|
1026
|
+
|
|
1027
|
+
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
1028
|
+
|
|
1029
|
+
// NO TOKEN IS A NO-OP, not an error. A deployment without a portal
|
|
1030
|
+
// configured must not crash the user stream.
|
|
1031
|
+
if( ! key ) return;
|
|
1032
|
+
|
|
1033
|
+
if( doc?.hubspotId ){
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
|
|
1037
|
+
return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token : key }) )?.id;
|
|
1038
|
+
|
|
1039
|
+
} catch ( error ){
|
|
1040
|
+
|
|
1041
|
+
// The cached contact was deleted in HubSpot (404) or is otherwise
|
|
1042
|
+
// unusable — fall through to search and create.
|
|
1043
|
+
if( error?.status !== 404 ) throw error;
|
|
1044
|
+
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const existing = await lookup({ email : doc?.email, fetcher, token : key });
|
|
1050
|
+
|
|
1051
|
+
return ( await send({
|
|
1052
|
+
doc,
|
|
1053
|
+
fetcher,
|
|
1054
|
+
method : existing ? 'PATCH' : 'POST',
|
|
1055
|
+
path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
|
|
1056
|
+
token : key
|
|
1057
|
+
}) )?.id;
|
|
1058
|
+
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
},
|
|
1062
|
+
email : false,
|
|
1063
|
+
inbound : false,
|
|
1064
|
+
lifecycle : false,
|
|
1065
|
+
resources : {
|
|
1066
|
+
audiences : false,
|
|
1067
|
+
prices : false,
|
|
1068
|
+
products : false,
|
|
1069
|
+
promotions : false
|
|
1070
|
+
},
|
|
1071
|
+
segment : false,
|
|
1072
|
+
sms : false,
|
|
1073
|
+
webhook : false
|
|
1074
|
+
},
|
|
1075
|
+
icon: icon$3,
|
|
1076
|
+
// Borrowed: the Drawbridge mark, because this is ours and never rendered.
|
|
1077
|
+
private : true,
|
|
1078
|
+
// Absent the token the hooks no-op, so a deployment without a portal simply
|
|
1079
|
+
// contributes nothing rather than failing.
|
|
1080
|
+
requires : [ 'HUBSPOT_ACCESS_TOKEN' ],
|
|
1081
|
+
slug : 'hubspot',
|
|
1082
|
+
status : () => 'active',
|
|
1083
|
+
// No workflow steps. The hooks are called by the user stream, not the builder.
|
|
1084
|
+
steps : {},
|
|
1085
|
+
tasks : () => [],
|
|
1086
|
+
title : 'HubSpot'
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Klaviyo, exported from the brand kit and left as authored — the fills are the
|
|
1090
|
+
// vendor's own mark, not a recolour.
|
|
1091
|
+
//
|
|
1092
|
+
// A .js wrapper around otherwise untouched SVG so `node --test` can run against
|
|
1093
|
+
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
1094
|
+
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
1095
|
+
var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
314
1096
|
<rect width="500" height="500" fill="white"/>
|
|
315
1097
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
316
1098
|
</svg>`;
|
|
317
1099
|
|
|
318
|
-
|
|
319
|
-
// one with an old date keeps the response shape that date shipped with — which
|
|
320
|
-
// is the point: bumping it is a deliberate act with a changelog to read, not
|
|
321
|
-
// something that drifts under us.
|
|
322
|
-
const REVISION = '2026-07-15';
|
|
323
|
-
|
|
324
|
-
const api = async ( path, { fetcher = fetch, token } ) => {
|
|
1100
|
+
const api = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
325
1101
|
|
|
326
1102
|
const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
|
|
1103
|
+
...( payload && { body : JSON.stringify( payload ) }),
|
|
327
1104
|
headers : {
|
|
328
1105
|
// Bearer, not Klaviyo-API-Key — that header is for private keys, and
|
|
329
1106
|
// sending it with an OAuth token fails in a way that reads like a bad
|
|
330
1107
|
// token rather than a bad scheme.
|
|
331
1108
|
authorization : 'Bearer ' + token,
|
|
332
|
-
|
|
1109
|
+
...( payload && { 'content-type' : 'application/json' }),
|
|
1110
|
+
// Klaviyo pins its API by DATE. A request without this header is
|
|
1111
|
+
// refused, and one with an old date keeps the response shape that date
|
|
1112
|
+
// shipped with — which is the point: bumping it is a deliberate act
|
|
1113
|
+
// with a changelog to read, not something that drifts under us.
|
|
1114
|
+
revision : '2026-07-15'
|
|
333
1115
|
},
|
|
1116
|
+
method,
|
|
334
1117
|
signal : AbortSignal.timeout( 15000 )
|
|
335
1118
|
});
|
|
336
1119
|
|
|
@@ -343,272 +1126,553 @@ const api = async ( path, { fetcher = fetch, token } ) => {
|
|
|
343
1126
|
|
|
344
1127
|
}
|
|
345
1128
|
|
|
346
|
-
|
|
1129
|
+
// 204 on a subscription write — nothing to parse, and asking for json throws.
|
|
1130
|
+
return response.status === 204 ? null : response.json();
|
|
347
1131
|
|
|
348
1132
|
};
|
|
349
1133
|
|
|
350
|
-
// Klaviyo — contact
|
|
1134
|
+
// Klaviyo — contact sync, over OAuth.
|
|
1135
|
+
//
|
|
1136
|
+
// EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
|
|
1137
|
+
// how it authenticates, what it can be asked for, and what its workflow step
|
|
1138
|
+
// does. Nothing about Klaviyo lives in the api, in drawbridge-sync, or in a
|
|
1139
|
+
// form component.
|
|
1140
|
+
//
|
|
1141
|
+
// TWO MAPS, and they are different things rather than one thing split in half:
|
|
1142
|
+
//
|
|
1143
|
+
// hooks protocol operations. Called synchronously, return data, never
|
|
1144
|
+
// user-facing. Grouped by domain, mirroring the vocabulary.
|
|
1145
|
+
// steps workflow units. Queued, billable, offered in the builder by
|
|
1146
|
+
// trigger, and their type is stored in documents under a Mongo enum.
|
|
1147
|
+
//
|
|
1148
|
+
// A HOOK'S VALUE IS ITS ANSWER, which is why no separate `supports` map exists
|
|
1149
|
+
// to drift from this one:
|
|
351
1150
|
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// whether this exercise worked, and the things it forced out are the interesting
|
|
356
|
-
// part — clientAuth and pkce both exist because Klaviyo needs them and Google
|
|
357
|
-
// does not.
|
|
1151
|
+
// false declined — a decision recorded, not silence
|
|
1152
|
+
// function supported, and the body is here
|
|
1153
|
+
// {} supported, implemented in the repo that has the dependencies
|
|
358
1154
|
var klaviyo = {
|
|
359
1155
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
360
1156
|
// exchange without a code_verifier matching the challenge the consent
|
|
361
1157
|
// carried. Most vendors treat it as optional hardening; this one does not,
|
|
362
1158
|
// which is why it is a descriptor flag and not a global.
|
|
363
1159
|
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// header — and rejects the same pair sent as form fields, which is how every
|
|
367
|
-
// Google product wants it.
|
|
1160
|
+
// HTTP Basic on the token endpoint is the other thing Klaviyo does
|
|
1161
|
+
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
368
1162
|
auth : {
|
|
369
1163
|
oauth : {
|
|
370
|
-
// TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
|
|
371
|
-
//
|
|
372
|
-
// authorize is a page a HUMAN loads, and it lives on www. Pointing it at
|
|
373
|
-
// a.klaviyo.com -- their API host -- sends the merchant somewhere that
|
|
374
|
-
// never renders a consent screen, so the journey stalls with no error
|
|
375
|
-
// anybody can see.
|
|
376
|
-
//
|
|
377
|
-
// token is a server call and must stay on a.klaviyo.com: Klaviyo began
|
|
378
|
-
// blocking OAuth token traffic through www on 2025-03-31, so the mirror
|
|
379
|
-
// image of this mistake breaks the exchange instead of the consent.
|
|
380
|
-
authorize : 'https://www.klaviyo.com/oauth/authorize',
|
|
381
1164
|
// NAMES the env vars holding OUR application's client. One identity,
|
|
382
1165
|
// every merchant — the token is the merchant's and arrives from their
|
|
383
1166
|
// own consent, which is what stops one organization reading another's
|
|
384
1167
|
// data.
|
|
1168
|
+
//
|
|
385
1169
|
client : {
|
|
386
1170
|
id : 'KLAVIYO_OAUTH_CLIENT_ID',
|
|
387
1171
|
secret : 'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
388
1172
|
},
|
|
389
|
-
clientAuth : 'basic',
|
|
390
|
-
pkce : true,
|
|
391
|
-
// DECLARED, never derived from the slug. It is registered in Klaviyo's
|
|
392
|
-
// app settings and they refuse anything that does not byte-match, so
|
|
393
|
-
// it is a fact about someone else's records rather than a string this
|
|
394
|
-
// code computes. Deriving one from a provider key produced
|
|
395
|
-
// redirect_uri_mismatch on a connection nobody had touched.
|
|
396
1173
|
// Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
|
|
397
1174
|
// never mentions this at runtime — you discover it when a refresh fails
|
|
398
1175
|
// on a connection nobody touched — so it is declared, and it is why
|
|
399
1176
|
// auth.probe has to run on a schedule rather than only before a call.
|
|
400
|
-
|
|
401
|
-
|
|
1177
|
+
//
|
|
1178
|
+
// Token lifetime is NOT declared: the vendor states it on every
|
|
1179
|
+
// exchange, and a copy here would be a second answer that goes stale.
|
|
1180
|
+
expiry : 90 * 24 * 60 * 60,
|
|
1181
|
+
pkce : true,
|
|
402
1182
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
403
1183
|
// and must stay in the list; the rest are what a contact sync needs.
|
|
404
1184
|
scopes : 'accounts:read lists:read lists:write profiles:read profiles:write',
|
|
405
|
-
|
|
1185
|
+
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
1186
|
+
// the disconnect hook — three vendor addresses, two of them declared,
|
|
1187
|
+
// which is exactly the kind of split that goes unnoticed.
|
|
1188
|
+
urls : {
|
|
1189
|
+
// TWO DIFFERENT HOSTS, and swapping them fails in opposite ways.
|
|
1190
|
+
//
|
|
1191
|
+
// authorize is a page a HUMAN loads, and it lives on www. Pointing it
|
|
1192
|
+
// at a.klaviyo.com — their API host — sends the merchant somewhere
|
|
1193
|
+
// that never renders a consent screen, so the journey stalls with no
|
|
1194
|
+
// error anybody can see.
|
|
1195
|
+
//
|
|
1196
|
+
// token and revoke are server calls and must stay on a.klaviyo.com:
|
|
1197
|
+
// Klaviyo began blocking OAuth token traffic through www on
|
|
1198
|
+
// 2025-03-31, so the mirror image of this mistake breaks the exchange
|
|
1199
|
+
// instead of the consent.
|
|
1200
|
+
authorize : 'https://www.klaviyo.com/oauth/authorize',
|
|
1201
|
+
// WHERE THE MERCHANT LANDS — the dashboard, not drawbridge-api. The
|
|
1202
|
+
// `/api/` segment is Next's route-handler convention, which reads as
|
|
1203
|
+
// the api service to everyone who sees it; it is not, and the route
|
|
1204
|
+
// has never moved. build() pins it against the one callback route
|
|
1205
|
+
// that exists, because declared-but-wrong fails AFTER consent — a
|
|
1206
|
+
// 404 for someone who has already granted access.
|
|
1207
|
+
//
|
|
1208
|
+
// Registered in Klaviyo's own app settings, and they refuse anything
|
|
1209
|
+
// that does not byte-match, so it is a fact about someone else's
|
|
1210
|
+
// records rather than a string this code computes.
|
|
1211
|
+
redirect : '/api/connection/klaviyo/callback',
|
|
1212
|
+
revoke : 'https://a.klaviyo.com/oauth/revoke',
|
|
1213
|
+
token : 'https://a.klaviyo.com/oauth/token'
|
|
1214
|
+
}
|
|
406
1215
|
},
|
|
407
1216
|
type : 'oauth'
|
|
408
1217
|
},
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1218
|
+
// EVERYTHING A MERCHANT READS. Grouped by who it is for rather than by what
|
|
1219
|
+
// kind of sentence it is, so the question on the next vendor is "does a person
|
|
1220
|
+
// read this", which nobody gets wrong, instead of "is this marketing", which
|
|
1221
|
+
// someone will.
|
|
1222
|
+
//
|
|
1223
|
+
// `errors` is in here rather than at the top level, and that is not a
|
|
1224
|
+
// preference. The connection DOCUMENT carries its own `errors` array of
|
|
1225
|
+
// scope-drift entries, and the document is spread OVER the resolved manifest
|
|
1226
|
+
// downstream — so a top-level `errors` here would be silently replaced by that
|
|
1227
|
+
// array and this copy would never render. `fields`/`settings` already carry a
|
|
1228
|
+
// comment about the same collision.
|
|
1229
|
+
content : {
|
|
1230
|
+
|
|
1231
|
+
// Shown at disconnect, so it says what is lost and what is not.
|
|
1232
|
+
confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
|
|
1233
|
+
|
|
1234
|
+
description : [
|
|
1235
|
+
'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.',
|
|
1236
|
+
'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.',
|
|
1237
|
+
'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.'
|
|
1238
|
+
],
|
|
1239
|
+
|
|
1240
|
+
// KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
|
|
1241
|
+
// likely to grow — resources.* has already earned somewhere to put "we
|
|
1242
|
+
// could not load your lists" — so a new area adds a key here rather than a
|
|
1243
|
+
// second errors object somewhere else.
|
|
1244
|
+
//
|
|
1245
|
+
// `connect` no longer exists as a container above: its only other member
|
|
1246
|
+
// was `redirect`, which moved to auth.oauth.urls with the rest of the
|
|
1247
|
+
// vendor's addresses.
|
|
412
1248
|
errors : {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1249
|
+
connect : {
|
|
1250
|
+
denied : 'The Klaviyo authorization was declined, so nothing was connected.',
|
|
1251
|
+
invalid : 'We couldn\'t complete the Klaviyo connection. Try connecting again.'
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
|
|
1255
|
+
excerpt : 'Sync the contacts your campaigns collect into a Klaviyo list.',
|
|
1256
|
+
|
|
1257
|
+
// HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
|
|
1258
|
+
// rendered — four useful instructions no component displayed.
|
|
1259
|
+
guide : [
|
|
1260
|
+
'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
|
|
1261
|
+
'Sign in to Klaviyo if you are not already, and choose the account to connect.',
|
|
1262
|
+
'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
|
|
1263
|
+
'You can revoke access at any time from Klaviyo, under Integrations.'
|
|
1264
|
+
]
|
|
1265
|
+
|
|
416
1266
|
},
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
1267
|
+
// CAN A MERCHANT KEEP TWO OF THESE AT ONCE?
|
|
1268
|
+
//
|
|
1269
|
+
// Required, and false is a decision rather than a default. Shopify is
|
|
1270
|
+
// exclusive because a store maps to exactly one organization. Contact syncs
|
|
1271
|
+
// are destinations — someone can reasonably keep Klaviyo and Mailchimp both
|
|
1272
|
+
// current — so the exclusivity that once applied when these were SENDERS is
|
|
1273
|
+
// deliberately gone. That was removed once already; declaring it out loud is
|
|
1274
|
+
// what stops it coming back by inference.
|
|
1275
|
+
exclusive : false,
|
|
423
1276
|
feature : 'organization:connection:klaviyo',
|
|
424
|
-
// Nothing typed. The consent returns the grant, and the account it belongs to
|
|
425
|
-
// is read back from Klaviyo rather than asked for.
|
|
426
1277
|
fields : [
|
|
427
1278
|
{
|
|
428
1279
|
key : 'account',
|
|
429
1280
|
label : 'Klaviyo account'
|
|
430
1281
|
},
|
|
431
1282
|
{
|
|
1283
|
+
// The choices come from the merchant's own account, so the field names
|
|
1284
|
+
// the capability and the client composes the url.
|
|
1285
|
+
hook : 'resources.audiences',
|
|
432
1286
|
input : 'select',
|
|
433
1287
|
key : 'list',
|
|
434
1288
|
label : 'Klaviyo list',
|
|
435
1289
|
message : 'Contacts your campaigns collect are synced into this list.',
|
|
436
1290
|
required : true,
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
1291
|
+
// Klaviyo's list endpoint carries no name filter, so the hook can only
|
|
1292
|
+
// match what it already fetched. A search box that searches one page is
|
|
1293
|
+
// worse than none, so the picker does not offer one.
|
|
1294
|
+
search : false
|
|
441
1295
|
}
|
|
442
1296
|
],
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
1297
|
+
// WHAT KIND OF THING THIS IS. One field, not two — `category` said the same
|
|
1298
|
+
// thing and was read by nothing, while `group` was quietly doing double duty
|
|
1299
|
+
// as the mutual-exclusion key. The exclusion moved to `exclusive` above, so
|
|
1300
|
+
// this is purely how a connection is grouped and labelled.
|
|
1301
|
+
group : 'contacts',
|
|
1302
|
+
|
|
1303
|
+
// Nothing typed at connect. The consent returns the grant, and the account it
|
|
1304
|
+
// belongs to is read back from Klaviyo rather than asked for.
|
|
446
1305
|
hooks : {
|
|
447
|
-
// Turn a fresh grant into settings worth showing. Without this the card
|
|
448
|
-
// renders an empty "Klaviyo account" field, because the merchant is never
|
|
449
|
-
// asked which account they connected — the consent already decided it and
|
|
450
|
-
// asking again would be a question we can answer ourselves.
|
|
451
|
-
'auth.connect' : async ({ fetcher, tokens }) => {
|
|
452
1306
|
|
|
453
|
-
|
|
1307
|
+
auth : {
|
|
454
1308
|
|
|
455
|
-
|
|
1309
|
+
// Turn a fresh grant into settings worth showing. Without this the card
|
|
1310
|
+
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1311
|
+
// never asked which account they connected — the consent already
|
|
1312
|
+
// decided it, and asking again would be a question we can answer.
|
|
1313
|
+
connect : async ({ fetcher, tokens }) => {
|
|
456
1314
|
|
|
457
|
-
|
|
458
|
-
account : account?.attributes?.contact_information?.organization_name || account?.id || null,
|
|
459
|
-
accountId : account?.id || null
|
|
460
|
-
};
|
|
1315
|
+
const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
|
|
461
1316
|
|
|
462
|
-
|
|
463
|
-
// Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
|
|
464
|
-
// grant live in the merchant's account, so a disconnect that looks
|
|
465
|
-
// complete here still shows Drawbridge with access over there.
|
|
466
|
-
//
|
|
467
|
-
// Basic auth with our client, exactly like the token exchange — the token
|
|
468
|
-
// being revoked is the subject, not the credential.
|
|
469
|
-
// The lists a merchant can sync into, for the picker on their connection.
|
|
470
|
-
//
|
|
471
|
-
// PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
|
|
472
|
-
// so a single call quietly returns the first ten lists and an account with
|
|
473
|
-
// more would show a picker missing the one they wanted — with nothing to
|
|
474
|
-
// indicate anything was cut. Follows links.next, bounded so a runaway
|
|
475
|
-
// cursor cannot spin forever.
|
|
476
|
-
'catalog.audiences' : async ({ fetcher, token }) => {
|
|
1317
|
+
const account = body?.data?.[ 0 ];
|
|
477
1318
|
|
|
478
|
-
|
|
1319
|
+
return {
|
|
1320
|
+
account : account?.attributes?.contact_information?.organization_name || account?.id || null,
|
|
1321
|
+
accountId : account?.id || null
|
|
1322
|
+
};
|
|
479
1323
|
|
|
480
|
-
|
|
481
|
-
let pages = 0;
|
|
1324
|
+
},
|
|
482
1325
|
|
|
483
|
-
|
|
1326
|
+
// Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
|
|
1327
|
+
// grant live in the merchant's account, so a disconnect that looks
|
|
1328
|
+
// complete here still shows Drawbridge with access over there.
|
|
1329
|
+
//
|
|
1330
|
+
// Basic auth with our client, exactly like the token exchange — the
|
|
1331
|
+
// token being revoked is the subject, not the credential.
|
|
1332
|
+
disconnect : async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
|
|
1333
|
+
|
|
1334
|
+
const token = settings?.refreshToken || settings?.accessToken;
|
|
1335
|
+
|
|
1336
|
+
if( ! token ) return { revoked : false };
|
|
1337
|
+
|
|
1338
|
+
const response = await fetcher( manifest.auth.oauth.urls.revoke, {
|
|
1339
|
+
body : new URLSearchParams({
|
|
1340
|
+
token,
|
|
1341
|
+
token_type_hint : settings?.refreshToken ? 'refresh_token' : 'access_token'
|
|
1342
|
+
}),
|
|
1343
|
+
headers : {
|
|
1344
|
+
authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ),
|
|
1345
|
+
'content-type' : 'application/x-www-form-urlencoded'
|
|
1346
|
+
},
|
|
1347
|
+
method : 'POST',
|
|
1348
|
+
signal : AbortSignal.timeout( 15000 )
|
|
1349
|
+
});
|
|
484
1350
|
|
|
485
|
-
|
|
1351
|
+
// A grant already revoked at the vendor answers non-2xx, and that is
|
|
1352
|
+
// the outcome we wanted — surfacing it as a failure would leave a
|
|
1353
|
+
// merchant unable to finish disconnecting.
|
|
1354
|
+
return { revoked : response.ok };
|
|
486
1355
|
|
|
487
|
-
|
|
1356
|
+
},
|
|
488
1357
|
|
|
489
|
-
|
|
1358
|
+
// THE MINT IS THE PROBE. Asking "is this token still good" by
|
|
1359
|
+
// inspecting what we stored answers the wrong question — a grant
|
|
1360
|
+
// revoked inside Klaviyo still looks perfect in our database. Spending
|
|
1361
|
+
// the refresh token is the only thing that asks Klaviyo.
|
|
1362
|
+
//
|
|
1363
|
+
// It also keeps the grant warm against the 90-day idle window above.
|
|
1364
|
+
probe : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
|
|
1365
|
+
|
|
1366
|
+
const token = await accessToken({
|
|
1367
|
+
clientId,
|
|
1368
|
+
clientSecret,
|
|
1369
|
+
fetcher,
|
|
1370
|
+
// Mint even if the stored token still looks good — a probe that
|
|
1371
|
+
// short-circuits never reaches Klaviyo and reports healthy on a
|
|
1372
|
+
// grant revoked an hour ago.
|
|
1373
|
+
force : true,
|
|
1374
|
+
manifest,
|
|
1375
|
+
settings
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
return { ok : Boolean( token ) };
|
|
490
1379
|
|
|
491
|
-
|
|
1380
|
+
},
|
|
492
1381
|
|
|
493
|
-
|
|
1382
|
+
// Klaviyo scopes are fixed at app level and re-consented, not drifted.
|
|
1383
|
+
scopes : false,
|
|
494
1384
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1385
|
+
// KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
|
|
1386
|
+
// client_id/client_secret pair as body fields. Everything else about the
|
|
1387
|
+
// request is standard, so this is the shared implementation told the one
|
|
1388
|
+
// thing that differs — in Klaviyo's own file, beside the rest of what
|
|
1389
|
+
// makes Klaviyo unusual, rather than as a flag a caller has to know to
|
|
1390
|
+
// read.
|
|
1391
|
+
token : ( args ) => authToken({ ...args, basic : true })
|
|
498
1392
|
|
|
499
|
-
|
|
1393
|
+
},
|
|
500
1394
|
|
|
501
|
-
}
|
|
502
1395
|
|
|
503
|
-
|
|
1396
|
+
// No commerce here. Klaviyo tracks orders, but Drawbridge's order data comes
|
|
1397
|
+
// from the store that took the money — a second source for the same event
|
|
1398
|
+
// is two answers to "did this person buy", and the one we can bill from is
|
|
1399
|
+
// the store's.
|
|
1400
|
+
commerce : false,
|
|
1401
|
+
|
|
1402
|
+
// The verb the contacts.sync step points at. It does the work — including
|
|
1403
|
+
// writing the profile id back onto the lead — and returns what happened.
|
|
1404
|
+
contacts : {
|
|
1405
|
+
|
|
1406
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1407
|
+
// different thing from deleting the profile.
|
|
1408
|
+
remove : false,
|
|
1409
|
+
|
|
1410
|
+
sync : async ({ contact, fetcher, lead, settings, suppressed, token }) => {
|
|
1411
|
+
|
|
1412
|
+
const list = settings?.list;
|
|
1413
|
+
|
|
1414
|
+
// incomplete() already stops a connection reaching Active without a
|
|
1415
|
+
// list; this is the belt to that braces. A workflow saved before the
|
|
1416
|
+
// list was chosen must not silently write into nothing.
|
|
1417
|
+
if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', skipped : true };
|
|
1418
|
+
|
|
1419
|
+
const email = lead?.canonical?.email?.value || lead?.email;
|
|
1420
|
+
|
|
1421
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
1422
|
+
|
|
1423
|
+
// A Klaviyo profile is keyed on an email, so IDENTITY comes from the
|
|
1424
|
+
// lead. RANKING comes from the contact behind it, because only a
|
|
1425
|
+
// contact knows money: order attribution is person-level and merges
|
|
1426
|
+
// across every address a human used, so revenue and orders cannot
|
|
1427
|
+
// exist on a single-address record.
|
|
1428
|
+
const totals = contact?.totals || {};
|
|
1429
|
+
|
|
1430
|
+
const profile = await api( '/profiles/', {
|
|
1431
|
+
fetcher,
|
|
1432
|
+
method : 'POST',
|
|
1433
|
+
payload : {
|
|
1434
|
+
data : {
|
|
1435
|
+
attributes : {
|
|
1436
|
+
email,
|
|
1437
|
+
...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
|
|
1438
|
+
properties : {
|
|
1439
|
+
drawbridge_campaigns : ( contact?.campaigns || [] ).length,
|
|
1440
|
+
drawbridge_draws : totals.draws || 0,
|
|
1441
|
+
drawbridge_entries : totals.entries || 0,
|
|
1442
|
+
drawbridge_orders : totals.orders || 0,
|
|
1443
|
+
// Campaign-attributed, NOT lifetime. A merchant running
|
|
1444
|
+
// Shopify already has lifetime revenue in Klaviyo through
|
|
1445
|
+
// Klaviyo's own integration; what only we can say is how
|
|
1446
|
+
// much a campaign drove. Named so the two cannot be
|
|
1447
|
+
// mistaken for one another in a segment builder.
|
|
1448
|
+
drawbridge_revenue : totals.gross || 0
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
type : 'profile'
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
token
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
const profileId = profile?.data?.id;
|
|
1458
|
+
|
|
1459
|
+
if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
|
|
1460
|
+
|
|
1461
|
+
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
1462
|
+
//
|
|
1463
|
+
// Omitting them means Klaviyo never learns they said no, so the
|
|
1464
|
+
// merchant can import them from somewhere else and start mailing them
|
|
1465
|
+
// again. Pushing them as unsubscribed makes the suppression travel
|
|
1466
|
+
// with the person, which is the reason this connection is allowed to
|
|
1467
|
+
// send anything at all.
|
|
1468
|
+
//
|
|
1469
|
+
// `suppressed` arrives as an argument because canSend() is sync's —
|
|
1470
|
+
// a manifest cannot reach it, and this rule is too important to infer.
|
|
1471
|
+
await api( '/profile-subscription-bulk-create-jobs/', {
|
|
1472
|
+
fetcher,
|
|
1473
|
+
method : 'POST',
|
|
1474
|
+
payload : {
|
|
1475
|
+
data : {
|
|
1476
|
+
attributes : {
|
|
1477
|
+
profiles : {
|
|
1478
|
+
data : [ {
|
|
1479
|
+
attributes : {
|
|
1480
|
+
email,
|
|
1481
|
+
subscriptions : {
|
|
1482
|
+
email : { marketing : { consent : suppressed ? 'UNSUBSCRIBED' : 'SUBSCRIBED' } }
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
type : 'profile'
|
|
1486
|
+
} ]
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
relationships : { list : { data : { id : list, type : 'list' } } },
|
|
1490
|
+
type : 'profile-subscription-bulk-create-job'
|
|
1491
|
+
}
|
|
1492
|
+
},
|
|
1493
|
+
token
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
return {
|
|
1497
|
+
// Merged into `context` for later steps in this run.
|
|
1498
|
+
context : { klaviyoProfileId : profileId },
|
|
1499
|
+
message : suppressed
|
|
1500
|
+
? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
|
|
1501
|
+
: 'Synced to the Klaviyo list.',
|
|
1502
|
+
// Recorded on the run for support to read back, not a write
|
|
1503
|
+
// instruction — the hook has already written what it needed to.
|
|
1504
|
+
response : { klaviyoProfileId : profileId }
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
}
|
|
504
1508
|
|
|
505
1509
|
},
|
|
506
|
-
'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
|
|
507
1510
|
|
|
508
|
-
|
|
1511
|
+
// A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
|
|
1512
|
+
// register nothing with it, so listing four falses would be noise around a
|
|
1513
|
+
// single decision. Still explicit — absence would not say whether anybody
|
|
1514
|
+
// considered it.
|
|
1515
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
1516
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
1517
|
+
// these would be a second sender, which is the arrangement the platform
|
|
1518
|
+
// sender replaced.
|
|
1519
|
+
email : false,
|
|
1520
|
+
segment : false,
|
|
1521
|
+
sms : false,
|
|
1522
|
+
inbound : false,
|
|
1523
|
+
|
|
1524
|
+
// Nothing to set up or tear down at the vendor: the grant is the whole
|
|
1525
|
+
// integration, and revoking it is auth.disconnect's job.
|
|
1526
|
+
lifecycle : false,
|
|
1527
|
+
|
|
1528
|
+
resources : {
|
|
1529
|
+
|
|
1530
|
+
// The lists a merchant can sync into, for the picker on their
|
|
1531
|
+
// connection.
|
|
1532
|
+
//
|
|
1533
|
+
// PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to
|
|
1534
|
+
// it, so one call quietly returns the first ten lists and an account
|
|
1535
|
+
// with more shows a picker missing the one they wanted, with nothing to
|
|
1536
|
+
// indicate anything was cut.
|
|
1537
|
+
audiences : async ({ cursor, fetcher, limit = 100, search, token }) => {
|
|
509
1538
|
|
|
510
|
-
|
|
1539
|
+
// `limit` is what the CALLER wants back rather than what one request
|
|
1540
|
+
// can carry — the loop keeps pulling until it has that many or the
|
|
1541
|
+
// vendor runs out, and stops as soon as it does.
|
|
1542
|
+
const audiences = [];
|
|
511
1543
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1544
|
+
let next = cursor
|
|
1545
|
+
? '/lists?page%5Bsize%5D=10&page%5Bcursor%5D=' + encodeURIComponent( cursor )
|
|
1546
|
+
: '/lists?page%5Bsize%5D=10';
|
|
1547
|
+
|
|
1548
|
+
let pages = 0;
|
|
1549
|
+
|
|
1550
|
+
while( next && audiences.length < limit && pages < 20 ){
|
|
1551
|
+
|
|
1552
|
+
const body = await api( next, { fetcher, token });
|
|
1553
|
+
|
|
1554
|
+
for( const list of ( body?.data || [] ) ){
|
|
1555
|
+
|
|
1556
|
+
audiences.push({ id : list.id, title : list?.attributes?.name || list.id });
|
|
1557
|
+
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const link = body?.links?.next;
|
|
524
1561
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1562
|
+
// Klaviyo returns an absolute url; only the part after /api travels
|
|
1563
|
+
// on, because `api` prefixes its own base.
|
|
1564
|
+
next = link ? String( link ).replace( /^https:\/\/a\.klaviyo\.com\/api/, '' ) : null;
|
|
1565
|
+
|
|
1566
|
+
pages = pages + 1;
|
|
1567
|
+
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Klaviyo's list endpoint has no name filter, so a search term is
|
|
1571
|
+
// applied to what came back. Honest about its own limits: with more
|
|
1572
|
+
// lists than `limit`, a term matching only later ones finds nothing.
|
|
1573
|
+
const term = String( search?.value || '' ).trim().toLowerCase();
|
|
1574
|
+
|
|
1575
|
+
return {
|
|
1576
|
+
items : term
|
|
1577
|
+
? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
|
|
1578
|
+
: audiences,
|
|
1579
|
+
pageInfo : {
|
|
1580
|
+
endCursor : next,
|
|
1581
|
+
hasNextPage : Boolean( next )
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
},
|
|
1586
|
+
|
|
1587
|
+
// Klaviyo sells no products and mints no discount codes.
|
|
1588
|
+
prices : false,
|
|
1589
|
+
products : false,
|
|
1590
|
+
promotions : false
|
|
529
1591
|
|
|
530
1592
|
},
|
|
531
|
-
// THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
|
|
532
|
-
// what we stored answers the wrong question — a grant revoked inside
|
|
533
|
-
// Klaviyo still looks perfect in our database. Spending the refresh token
|
|
534
|
-
// is the only thing that asks Klaviyo.
|
|
535
|
-
//
|
|
536
|
-
// It also keeps the grant warm: Klaviyo expires a refresh token after 90
|
|
537
|
-
// days of NON-USE, so a connection nobody touches dies silently without
|
|
538
|
-
// this running.
|
|
539
|
-
'auth.probe' : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
|
|
540
|
-
|
|
541
|
-
const token = await accessToken({
|
|
542
|
-
clientId,
|
|
543
|
-
clientSecret,
|
|
544
|
-
fetcher,
|
|
545
|
-
// Mint even if the stored token still looks good — a probe that
|
|
546
|
-
// short-circuits never reaches Klaviyo and reports healthy on a
|
|
547
|
-
// grant revoked an hour ago.
|
|
548
|
-
force : true,
|
|
549
|
-
manifest,
|
|
550
|
-
settings
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
return { ok : Boolean( token ) };
|
|
554
1593
|
|
|
555
|
-
|
|
1594
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
1595
|
+
webhook : false
|
|
1596
|
+
|
|
556
1597
|
},
|
|
557
|
-
icon: icon$
|
|
558
|
-
// A grant with no list chosen is authenticated and useless. The list cannot
|
|
559
|
-
// be part of the consent flow — enumerating lists needs the token the consent
|
|
560
|
-
// returns — so it is always a second step, and the card must say so rather
|
|
561
|
-
// than showing Active over nothing.
|
|
562
|
-
incomplete : ( data ) => ( data?.settings?.list
|
|
563
|
-
? null
|
|
564
|
-
: 'Choose which Klaviyo list your contacts should sync into.'
|
|
565
|
-
),
|
|
566
|
-
label : 'klaviyo',
|
|
1598
|
+
icon: icon$2,
|
|
567
1599
|
requires : [
|
|
568
1600
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
569
1601
|
'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
570
1602
|
],
|
|
571
|
-
setup : [
|
|
572
|
-
'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
|
|
573
|
-
'Sign in to Klaviyo if you are not already, and choose the account to connect.',
|
|
574
|
-
'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
|
|
575
|
-
'You can revoke access at any time from Klaviyo, under Integrations.'
|
|
576
|
-
],
|
|
577
1603
|
slug : 'klaviyo',
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1604
|
+
// ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
|
|
1605
|
+
// is already the merchant-facing copy channel and is already rendered.
|
|
1606
|
+
//
|
|
1607
|
+
// A grant with no list chosen is authenticated and useless. The list cannot be
|
|
1608
|
+
// part of the consent flow — enumerating lists needs the token the consent
|
|
1609
|
+
// returns — so it is always a second step, and the card must say Pending
|
|
1610
|
+
// rather than Active over nothing.
|
|
1611
|
+
//
|
|
1612
|
+
// Otherwise the credential's own verdict stands. A manifest can only ever
|
|
1613
|
+
// DOWNGRADE: it can see the settings, and it cannot see whether the grant was
|
|
1614
|
+
// revoked at Klaviyo an hour ago.
|
|
1615
|
+
//
|
|
1616
|
+
// Computed at read time rather than written, for the same reason
|
|
1617
|
+
// shopifyMissingScopes is: it becomes true the moment a merchant clears the
|
|
1618
|
+
// list, without waiting for something to notice and write it down.
|
|
1619
|
+
status : ( data ) => ( data?.settings?.list ? data.status : 'pending' ),
|
|
1620
|
+
|
|
1621
|
+
steps : {
|
|
1622
|
+
|
|
1623
|
+
contacts : {
|
|
1624
|
+
|
|
1625
|
+
// A DECLARATION, not the work. It names the hook that does the work, and
|
|
1626
|
+
// says where that hook's values belong. Nested like the hooks, and the
|
|
1627
|
+
// nesting IS the name: this is `step.contacts.sync`, which is what a
|
|
1628
|
+
// workflow document stores.
|
|
1629
|
+
//
|
|
1630
|
+
// A function, so it can depend on what this deployment or this
|
|
1631
|
+
// merchant's connection knows — a static object would have to be true
|
|
1632
|
+
// for every deployment at publish time.
|
|
1633
|
+
sync : ({ data }) => ({
|
|
1634
|
+
|
|
1635
|
+
hook : 'contacts.sync',
|
|
1636
|
+
|
|
1637
|
+
// The account the merchant actually connected, read back by
|
|
1638
|
+
// auth.connect. The builder reads "Sync contact to Acme Co" rather
|
|
1639
|
+
// than a label that could be any of their Klaviyo accounts.
|
|
1640
|
+
key : 'Sync contact to ' + ( data?.settings?.account || 'Klaviyo' ),
|
|
1641
|
+
|
|
1642
|
+
queue : 'connection',
|
|
1643
|
+
|
|
1644
|
+
// Nothing for a merchant to configure on the step itself — the list
|
|
1645
|
+
// is chosen once on the connection. Declared empty rather than
|
|
1646
|
+
// omitted, so "this step takes no settings" and "nobody thought about
|
|
1647
|
+
// settings" are different statements.
|
|
1648
|
+
settings : {},
|
|
1649
|
+
|
|
1650
|
+
// BOTH triggers. lead.insert alone only ever fires for someone with
|
|
1651
|
+
// no history yet — a brand-new entrant has no orders and no revenue,
|
|
1652
|
+
// so a profile written then carries an email and nothing else.
|
|
1653
|
+
// Crossing into a segment is the moment the ranking data exists.
|
|
1654
|
+
triggers : [ 'lead.insert', 'segment.contact.add' ],
|
|
1655
|
+
|
|
1656
|
+
// One source for cost: what the builder discloses before a merchant
|
|
1657
|
+
// adds this step, and what is charged when it runs.
|
|
1658
|
+
usage : { actions : 1 }
|
|
1659
|
+
|
|
1660
|
+
})
|
|
1661
|
+
|
|
610
1662
|
}
|
|
611
|
-
|
|
1663
|
+
|
|
1664
|
+
},
|
|
1665
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
1666
|
+
tasks : ( data ) => ( data?.settings?.list
|
|
1667
|
+
? []
|
|
1668
|
+
: [
|
|
1669
|
+
{
|
|
1670
|
+
message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
|
|
1671
|
+
title : 'Choose a list'
|
|
1672
|
+
}
|
|
1673
|
+
]
|
|
1674
|
+
),
|
|
1675
|
+
|
|
612
1676
|
title : 'Klaviyo'
|
|
613
1677
|
};
|
|
614
1678
|
|
|
@@ -618,7 +1682,7 @@ var klaviyo = {
|
|
|
618
1682
|
// A .js wrapper around otherwise untouched SVG so `node --test` can run against
|
|
619
1683
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
620
1684
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
621
|
-
var icon$
|
|
1685
|
+
var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
622
1686
|
<rect width="500" height="500" fill="#FFE01B"/>
|
|
623
1687
|
<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"/>
|
|
624
1688
|
<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"/>
|
|
@@ -639,11 +1703,6 @@ const base = ( apiKey ) => {
|
|
|
639
1703
|
};
|
|
640
1704
|
|
|
641
1705
|
// Mailchimp — contact sync, not a sender.
|
|
642
|
-
//
|
|
643
|
-
// Deliberately no `group`. Mailchimp and SendGrid shared one while they were
|
|
644
|
-
// SENDERS, where an org picking two providers to send the same mail was
|
|
645
|
-
// meaningless. As contact syncs they are destinations, and a merchant could
|
|
646
|
-
// reasonably keep several up to date, so the exclusivity is gone.
|
|
647
1706
|
var mailchimp = {
|
|
648
1707
|
// Keys TODAY. Mailchimp integrations authenticate with OAuth 2 (authorization
|
|
649
1708
|
// code) and that is where this goes, so the endpoints are recorded here
|
|
@@ -664,13 +1723,28 @@ var mailchimp = {
|
|
|
664
1723
|
auth : {
|
|
665
1724
|
type : 'keys'
|
|
666
1725
|
},
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1726
|
+
// EVERYTHING A MERCHANT READS. `errors` belongs in here rather than at the
|
|
1727
|
+
// top level because the connection DOCUMENT carries its own `errors` array
|
|
1728
|
+
// and the document is spread OVER the resolved manifest downstream — a
|
|
1729
|
+
// top-level one would be replaced by that array and never render.
|
|
1730
|
+
content : {
|
|
1731
|
+
confirm : 'Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
|
|
1732
|
+
description : [
|
|
1733
|
+
'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.',
|
|
1734
|
+
'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.'
|
|
1735
|
+
],
|
|
1736
|
+
excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
|
|
1737
|
+
guide : [
|
|
1738
|
+
'In Mailchimp, open Account & billing, then Extras, then API keys.',
|
|
1739
|
+
'Create a key and copy it.',
|
|
1740
|
+
'Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on.'
|
|
1741
|
+
]
|
|
1742
|
+
},
|
|
1743
|
+
// Mailchimp and SendGrid shared a group while they were SENDERS, where an org
|
|
1744
|
+
// picking two providers to send the same mail was meaningless. As contact
|
|
1745
|
+
// syncs they are destinations, and a merchant could reasonably keep several
|
|
1746
|
+
// up to date, so the exclusivity is gone.
|
|
1747
|
+
exclusive : false,
|
|
674
1748
|
feature : 'organization:connection:mailchimp',
|
|
675
1749
|
fields : [
|
|
676
1750
|
{
|
|
@@ -687,36 +1761,66 @@ var mailchimp = {
|
|
|
687
1761
|
key : 'audience',
|
|
688
1762
|
label : 'Mailchimp audience',
|
|
689
1763
|
message : 'Contacts your campaigns collect are synced into this audience.',
|
|
1764
|
+
hook : 'resources.audiences',
|
|
690
1765
|
required : true,
|
|
691
|
-
|
|
1766
|
+
// Mailchimp's /lists takes no name filter either — same reason.
|
|
1767
|
+
search : false
|
|
692
1768
|
}
|
|
693
1769
|
],
|
|
694
|
-
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// against total_items so an account past a thousand still resolves.
|
|
1770
|
+
group : 'contacts',
|
|
1771
|
+
// A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
|
|
1772
|
+
// else is built yet, because audience sync has not shipped. Every false here
|
|
1773
|
+
// is "not yet" rather than "never" — when the sync lands, probe and
|
|
1774
|
+
// contacts.sync are the first to flip.
|
|
700
1775
|
hooks : {
|
|
701
|
-
'catalog.audiences' : async ({ fetcher = fetch, settings }) => {
|
|
702
1776
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1777
|
+
auth : {
|
|
1778
|
+
// Implemented outside this package: storing a typed key needs no vendor
|
|
1779
|
+
// call, so the api's own form handler does it.
|
|
1780
|
+
connect : {},
|
|
1781
|
+
disconnect : {},
|
|
1782
|
+
probe : false,
|
|
1783
|
+
scopes : false,
|
|
1784
|
+
// Keys today. When Mailchimp's OAuth lands this becomes a wrapper that
|
|
1785
|
+
// follows the exchange with /oauth2/metadata — the data-centre call that
|
|
1786
|
+
// is the whole reason its OAuth cannot be pure configuration.
|
|
1787
|
+
token : false
|
|
1788
|
+
},
|
|
1789
|
+
commerce : false,
|
|
1790
|
+
contacts : { remove : false, sync : false },
|
|
1791
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
1792
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
1793
|
+
// these would be a second sender, which is the arrangement the platform
|
|
1794
|
+
// sender replaced.
|
|
1795
|
+
email : false,
|
|
1796
|
+
segment : false,
|
|
1797
|
+
sms : false,
|
|
1798
|
+
inbound : false,
|
|
1799
|
+
lifecycle : false,
|
|
1800
|
+
resources : {
|
|
1801
|
+
|
|
1802
|
+
// The audiences a merchant can sync into, for the picker on their
|
|
1803
|
+
// connection.
|
|
1804
|
+
//
|
|
1805
|
+
// count DEFAULTS TO 10 and maxes at 1000 (Mailchimp's own OpenAPI spec),
|
|
1806
|
+
// so leaving it unset returns the first ten audiences and looks entirely
|
|
1807
|
+
// successful — the same silent truncation Klaviyo has, at a different
|
|
1808
|
+
// number. Paged against total_items so an account past a thousand still
|
|
1809
|
+
// resolves.
|
|
1810
|
+
audiences : async ({ cursor, fetcher = fetch, limit = 100, search, settings }) => {
|
|
711
1811
|
|
|
712
|
-
|
|
713
|
-
let total = null;
|
|
1812
|
+
const key = settings?.apiKey;
|
|
714
1813
|
|
|
715
|
-
|
|
1814
|
+
const count = Math.min( limit, 1000 );
|
|
1815
|
+
const offset = Number( cursor || 0 );
|
|
716
1816
|
|
|
717
1817
|
const response = await fetcher(
|
|
718
|
-
base( key ) + '/lists?count=
|
|
719
|
-
{
|
|
1818
|
+
base( key ) + '/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
|
|
1819
|
+
{
|
|
1820
|
+
// Basic with any username — Mailchimp reads only the password half.
|
|
1821
|
+
headers : { authorization : 'Basic ' + Buffer.from( 'drawbridge:' + key ).toString( 'base64' ) },
|
|
1822
|
+
signal : AbortSignal.timeout( 15000 )
|
|
1823
|
+
}
|
|
720
1824
|
);
|
|
721
1825
|
|
|
722
1826
|
if( ! response.ok ){
|
|
@@ -730,64 +1834,61 @@ var mailchimp = {
|
|
|
730
1834
|
|
|
731
1835
|
const body = await response.json();
|
|
732
1836
|
|
|
733
|
-
|
|
1837
|
+
const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
|
|
734
1838
|
|
|
735
|
-
|
|
1839
|
+
// Mailchimp's /lists takes no name filter, so a term is matched against
|
|
1840
|
+
// this page rather than the account. Stated plainly because it is a real
|
|
1841
|
+
// limit: a term matching only an audience on a later page finds nothing.
|
|
1842
|
+
const term = String( search?.value || '' ).trim().toLowerCase();
|
|
736
1843
|
|
|
737
|
-
|
|
1844
|
+
const items = term
|
|
1845
|
+
? audiences.filter( ( entry ) => entry.title.toLowerCase().includes( term ) )
|
|
1846
|
+
: audiences;
|
|
738
1847
|
|
|
739
|
-
|
|
740
|
-
|
|
1848
|
+
const nextOffset = offset + count;
|
|
1849
|
+
const more = nextOffset < Number( body?.total_items || 0 );
|
|
741
1850
|
|
|
742
|
-
|
|
1851
|
+
return {
|
|
1852
|
+
items,
|
|
1853
|
+
pageInfo : {
|
|
1854
|
+
endCursor : more ? String( nextOffset ) : null,
|
|
1855
|
+
hasNextPage : more
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
743
1858
|
|
|
744
|
-
|
|
1859
|
+
},
|
|
1860
|
+
prices : false,
|
|
1861
|
+
products : false,
|
|
1862
|
+
promotions : false
|
|
745
1863
|
|
|
746
1864
|
}
|
|
1865
|
+
,
|
|
1866
|
+
|
|
1867
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
1868
|
+
webhook : false
|
|
747
1869
|
},
|
|
748
|
-
icon: icon$
|
|
1870
|
+
icon: icon$1,
|
|
1871
|
+
slug : 'mailchimp',
|
|
749
1872
|
// A key with no audience chosen is authenticated and inert. Mailchimp also
|
|
750
1873
|
// needs its merge fields created on that audience before any Drawbridge total
|
|
751
1874
|
// can be written to a member — unlike Klaviyo, its custom fields are not
|
|
752
1875
|
// schemaless — so the audience must be picked before lifecycle.register has
|
|
753
1876
|
// anything to register against.
|
|
754
|
-
|
|
755
|
-
? null
|
|
756
|
-
: 'Choose which Mailchimp audience your contacts should sync into.'
|
|
757
|
-
),
|
|
758
|
-
label : 'mailchimp',
|
|
759
|
-
// Uniform surface, honest answers. A key is stored and can be removed; nothing
|
|
760
|
-
// else is built yet, because audience sync has not shipped. Every false here
|
|
761
|
-
// is "not yet", not "never" — when the sync lands, probe and catalog become
|
|
762
|
-
// the first two to flip.
|
|
763
|
-
supports : {
|
|
764
|
-
'auth.connect' : true,
|
|
765
|
-
'auth.disconnect' : true,
|
|
766
|
-
'auth.probe' : false,
|
|
767
|
-
'auth.scopes' : false,
|
|
768
|
-
'catalog.audiences' : true,
|
|
769
|
-
'catalog.prices' : false,
|
|
770
|
-
'catalog.products' : false,
|
|
771
|
-
'catalog.promotions' : false,
|
|
772
|
-
'inbound.event' : false,
|
|
773
|
-
'inbound.process' : false,
|
|
774
|
-
'inbound.receive' : false,
|
|
775
|
-
'inbound.verify' : false,
|
|
776
|
-
'lifecycle.cleanup' : false,
|
|
777
|
-
'lifecycle.register' : false,
|
|
778
|
-
'lifecycle.rehydrate' : false
|
|
779
|
-
},
|
|
780
|
-
setup : [
|
|
781
|
-
'In Mailchimp, open Account & billing, then Extras, then API keys.',
|
|
782
|
-
'Create a key and copy it.',
|
|
783
|
-
'Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on.'
|
|
784
|
-
],
|
|
785
|
-
slug : 'mailchimp',
|
|
1877
|
+
status : ( data ) => ( data?.settings?.audience ? data.status : 'pending' ),
|
|
786
1878
|
// No steps: audience sync has not shipped, so this vendor contributes nothing
|
|
787
1879
|
// to a workflow yet. An empty steps object is the honest declaration — the
|
|
788
1880
|
// catalog renders the connection, and no builder offers a step it cannot run.
|
|
789
1881
|
steps : {},
|
|
790
|
-
tasks : () => [
|
|
1882
|
+
tasks : ( data ) => [
|
|
1883
|
+
...( data?.settings?.audience
|
|
1884
|
+
? []
|
|
1885
|
+
: [
|
|
1886
|
+
{
|
|
1887
|
+
message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
|
|
1888
|
+
title : 'Choose an audience'
|
|
1889
|
+
}
|
|
1890
|
+
]
|
|
1891
|
+
),
|
|
791
1892
|
{
|
|
792
1893
|
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.',
|
|
793
1894
|
title : 'Audience sync not available yet',
|
|
@@ -803,7 +1904,7 @@ var mailchimp = {
|
|
|
803
1904
|
// A .js wrapper around otherwise untouched SVG so `node --test` can run against
|
|
804
1905
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
805
1906
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
806
|
-
var icon
|
|
1907
|
+
var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
807
1908
|
<rect width="500" height="500" fill="white"/>
|
|
808
1909
|
<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"/>
|
|
809
1910
|
<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"/>
|
|
@@ -909,31 +2010,48 @@ var shopify = {
|
|
|
909
2010
|
auth : {
|
|
910
2011
|
type : 'install'
|
|
911
2012
|
},
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
//
|
|
915
|
-
//
|
|
2013
|
+
// EVERYTHING A MERCHANT READS.
|
|
2014
|
+
//
|
|
2015
|
+
// `errors` is in here rather than at the top level, and that is not a
|
|
2016
|
+
// preference: the connection DOCUMENT carries its own `errors` array of
|
|
2017
|
+
// scope-drift entries, and the document is spread OVER the resolved manifest
|
|
2018
|
+
// downstream — a top-level one would be replaced by that array and never
|
|
2019
|
+
// render.
|
|
916
2020
|
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
|
|
2021
|
+
// `connect` no longer exists as a container. Its other member was `redirect`,
|
|
2022
|
+
// which is a URL and now sits with the vendor's other addresses.
|
|
2023
|
+
content : {
|
|
2024
|
+
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.',
|
|
2025
|
+
description : [
|
|
2026
|
+
'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.',
|
|
2027
|
+
'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.',
|
|
2028
|
+
'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.'
|
|
2029
|
+
],
|
|
921
2030
|
errors : {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
2031
|
+
connect : {
|
|
2032
|
+
conflict : 'This store is already connected to another Drawbridge organization.',
|
|
2033
|
+
currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
|
|
2034
|
+
invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
|
|
2035
|
+
}
|
|
925
2036
|
},
|
|
2037
|
+
excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
|
|
2038
|
+
guide : [
|
|
2039
|
+
'Open the Drawbridge listing on the Shopify App Store.',
|
|
2040
|
+
'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
|
|
2041
|
+
'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
|
|
2042
|
+
'Come back here — the connections list updates on its own once the install lands.'
|
|
2043
|
+
],
|
|
2044
|
+
// Names where the link GOES rather than what it does: installing happens on
|
|
2045
|
+
// the App Store listing, and the dashboard must never imply a store can be
|
|
2046
|
+
// linked from inside it.
|
|
926
2047
|
redirect : {
|
|
927
2048
|
env : 'SHOPIFY_APP_LISTING_URL',
|
|
928
2049
|
title : 'View on the Shopify App Store'
|
|
929
2050
|
}
|
|
930
2051
|
},
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
'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.'
|
|
935
|
-
],
|
|
936
|
-
excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
|
|
2052
|
+
// ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
|
|
2053
|
+
// attributed order two possible sources.
|
|
2054
|
+
exclusive : true,
|
|
937
2055
|
feature : 'organization:connection:shopify',
|
|
938
2056
|
fields : [
|
|
939
2057
|
{
|
|
@@ -944,12 +2062,9 @@ var shopify = {
|
|
|
944
2062
|
label : 'Store domain'
|
|
945
2063
|
}
|
|
946
2064
|
],
|
|
947
|
-
group : 'ecommerce'
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
// finish, which is a credential problem rather than a setup one, so the
|
|
951
|
-
// stored status already says so.
|
|
952
|
-
incomplete : () => null,
|
|
2065
|
+
// Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
|
|
2066
|
+
// fact, which left nobody able to say which one a page read.
|
|
2067
|
+
group : 'commerce',
|
|
953
2068
|
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
954
2069
|
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
955
2070
|
// write for either. receive is the one hook that genuinely differs by
|
|
@@ -957,8 +2072,50 @@ var shopify = {
|
|
|
957
2072
|
// /compliance enforces the topic allowlist above, because answering one late
|
|
958
2073
|
// is a legal deadline rather than a retry.
|
|
959
2074
|
hooks : {
|
|
960
|
-
|
|
961
|
-
|
|
2075
|
+
|
|
2076
|
+
auth : {
|
|
2077
|
+
// The install completes inside Shopify admin; the api's callback stores
|
|
2078
|
+
// what it hands back. auth.probe is false deliberately: the health check
|
|
2079
|
+
// re-registers rather than answering "is this token still good", and
|
|
2080
|
+
// scope drift is its own hook because a token can be perfectly valid
|
|
2081
|
+
// while the grant is too narrow.
|
|
2082
|
+
connect : {},
|
|
2083
|
+
disconnect : {},
|
|
2084
|
+
probe : false,
|
|
2085
|
+
scopes : {},
|
|
2086
|
+
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2087
|
+
// through the shared OAuth runner.
|
|
2088
|
+
token : false
|
|
2089
|
+
},
|
|
2090
|
+
// Implemented in drawbridge-sync, which owns the attribution and the
|
|
2091
|
+
// controllers it needs. Declared here so the steps below can point at them:
|
|
2092
|
+
// a step naming a hook the vendor does not implement is a workflow that
|
|
2093
|
+
// accepts the step and then silently does nothing.
|
|
2094
|
+
commerce : {
|
|
2095
|
+
code : {},
|
|
2096
|
+
customer : {},
|
|
2097
|
+
order : {},
|
|
2098
|
+
product : {}
|
|
2099
|
+
},
|
|
2100
|
+
contacts : { remove : false, sync : false },
|
|
2101
|
+
|
|
2102
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's
|
|
2103
|
+
// scheme is exactly the shape it covers, so there is nothing vendor-specific
|
|
2104
|
+
// to write for either. receive is the one hook that genuinely differs by
|
|
2105
|
+
// channel: /events buffers whatever arrives with the shop domain stamped on;
|
|
2106
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
2107
|
+
// is a legal deadline rather than a retry.
|
|
2108
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2109
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2110
|
+
// these would be a second sender, which is the arrangement the platform
|
|
2111
|
+
// sender replaced.
|
|
2112
|
+
email : false,
|
|
2113
|
+
segment : false,
|
|
2114
|
+
sms : false,
|
|
2115
|
+
inbound : {
|
|
2116
|
+
event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
2117
|
+
process : {},
|
|
2118
|
+
receive : ({ channel, event, headers, payload }) => {
|
|
962
2119
|
|
|
963
2120
|
if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
964
2121
|
|
|
@@ -977,12 +2134,44 @@ var shopify = {
|
|
|
977
2134
|
provider : { id : headers[ inbound.headers.id ] || null }
|
|
978
2135
|
};
|
|
979
2136
|
|
|
2137
|
+
},
|
|
2138
|
+
verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
980
2139
|
},
|
|
981
|
-
|
|
2140
|
+
lifecycle : { cleanup : {}, health : {}, register : {}, rehydrate : {} },
|
|
2141
|
+
resources : {
|
|
2142
|
+
audiences : false,
|
|
2143
|
+
// Shopify has no separate price resource — a price belongs to a product
|
|
2144
|
+
// variant and arrives with it, so there is nothing for prices to answer
|
|
2145
|
+
// that products does not already.
|
|
2146
|
+
prices : false,
|
|
2147
|
+
products : {},
|
|
2148
|
+
promotions : {}
|
|
2149
|
+
}
|
|
2150
|
+
,
|
|
2151
|
+
|
|
2152
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
2153
|
+
webhook : false
|
|
982
2154
|
},
|
|
983
|
-
icon
|
|
2155
|
+
icon,
|
|
984
2156
|
inbound,
|
|
985
|
-
|
|
2157
|
+
// THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
|
|
2158
|
+
//
|
|
2159
|
+
// Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
|
|
2160
|
+
// in the shared resolver — a hardcoded vendor branch in code every vendor runs
|
|
2161
|
+
// through, which is the arrangement these manifests exist to remove.
|
|
2162
|
+
//
|
|
2163
|
+
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
2164
|
+
// connected connection. The app handle is NAMED by `requires` and read from
|
|
2165
|
+
// the env the resolver passes, never from process.env here.
|
|
2166
|
+
manage : ( data, env ) => {
|
|
2167
|
+
|
|
2168
|
+
const shop = data?.shop || data?.settings?.domain;
|
|
2169
|
+
|
|
2170
|
+
return shop
|
|
2171
|
+
? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
|
|
2172
|
+
: undefined;
|
|
2173
|
+
|
|
2174
|
+
},
|
|
986
2175
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
987
2176
|
// exists and the app is fully configured. Requiring all four means it can
|
|
988
2177
|
// never render half-configured — and absence of any one excludes the
|
|
@@ -993,139 +2182,150 @@ var shopify = {
|
|
|
993
2182
|
'SHOPIFY_APP_LISTING_URL',
|
|
994
2183
|
'SHOPIFY_APP_HANDLE'
|
|
995
2184
|
],
|
|
996
|
-
// The only vendor implementing most of the surface, which is why it was the
|
|
997
|
-
// one every slug branch in three repos was written for.
|
|
998
|
-
//
|
|
999
|
-
// auth.probe is false deliberately: the health check re-registers webhooks
|
|
1000
|
-
// rather than answering "is this token still good", and scope drift is its own
|
|
1001
|
-
// hook because a token can be perfectly valid while the grant is too narrow.
|
|
1002
|
-
supports : {
|
|
1003
|
-
'auth.connect' : true,
|
|
1004
|
-
'auth.disconnect' : true,
|
|
1005
|
-
'auth.probe' : false,
|
|
1006
|
-
'auth.scopes' : true,
|
|
1007
|
-
// Shopify has no separate price resource — a price belongs to a product
|
|
1008
|
-
// variant and arrives with it, so there is nothing for prices to answer
|
|
1009
|
-
// that products does not already.
|
|
1010
|
-
'catalog.audiences' : false,
|
|
1011
|
-
'catalog.prices' : false,
|
|
1012
|
-
'catalog.products' : true,
|
|
1013
|
-
'catalog.promotions' : true,
|
|
1014
|
-
'inbound.event' : true,
|
|
1015
|
-
'inbound.process' : true,
|
|
1016
|
-
'inbound.receive' : true,
|
|
1017
|
-
'inbound.verify' : true,
|
|
1018
|
-
'lifecycle.cleanup' : true,
|
|
1019
|
-
'lifecycle.register' : true,
|
|
1020
|
-
'lifecycle.rehydrate' : true
|
|
1021
|
-
},
|
|
1022
|
-
setup : [
|
|
1023
|
-
'Open the Drawbridge listing on the Shopify App Store.',
|
|
1024
|
-
'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
|
|
1025
|
-
'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
|
|
1026
|
-
'Come back here — the connections list updates on its own once the install lands.'
|
|
1027
|
-
],
|
|
1028
2185
|
slug : 'shopify',
|
|
2186
|
+
// The install is the whole configuration — Shopify hands back the shop and
|
|
2187
|
+
// there is nothing further to choose. `shop` absent means the install did not
|
|
2188
|
+
// finish, which is a credential problem rather than a setup one, so the
|
|
2189
|
+
// stored status already says so.
|
|
2190
|
+
// Nothing to add — no setting can make this connection unusable, so the
|
|
2191
|
+
// credential's own verdict stands.
|
|
2192
|
+
status : ( data ) => data?.status,
|
|
1029
2193
|
// Step types name the CAPABILITY, not this vendor. A second store platform
|
|
1030
2194
|
// implements the same four commerce steps, and the connection on the step
|
|
1031
2195
|
// says which store it runs against — so a merchant sees one "Create
|
|
1032
2196
|
// customer", not one per platform. The three connection.* steps are not
|
|
1033
2197
|
// commerce at all: any vendor holding a rotating credential needs them.
|
|
2198
|
+
// Step types name the CAPABILITY, not this vendor. A second store platform
|
|
2199
|
+
// implements the same commerce steps, and the connection on the step says
|
|
2200
|
+
// which store it runs against — so a merchant sees one "Create customer", not
|
|
2201
|
+
// one per platform.
|
|
2202
|
+
//
|
|
2203
|
+
// Nested for readability and flattened to the stored name, at whatever depth:
|
|
2204
|
+
// steps.commerce.customer.insert is `step.commerce.customer.insert` on a
|
|
2205
|
+
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2206
|
+
//
|
|
2207
|
+
// EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
|
|
2208
|
+
// The bodies these point at still live in drawbridge-sync; moving them is the
|
|
2209
|
+
// next unit, and commerce.order.record is the one that decides whether the
|
|
2210
|
+
// shape holds — 569 lines and 15 controller calls.
|
|
1034
2211
|
steps : {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
{ key : 'shopifyDiscountId', label : 'Shopify Discount ID' }
|
|
1052
|
-
],
|
|
1053
|
-
settings : {
|
|
1054
|
-
discount : {
|
|
1055
|
-
required : true,
|
|
1056
|
-
shape : {
|
|
1057
|
-
id : { required : true, type : 'string' }
|
|
2212
|
+
|
|
2213
|
+
commerce : {
|
|
2214
|
+
|
|
2215
|
+
code : {
|
|
2216
|
+
issue : () => ({
|
|
2217
|
+
hook : 'commerce.code',
|
|
2218
|
+
key : 'Issue a discount code',
|
|
2219
|
+
queue : 'connection',
|
|
2220
|
+
settings : {
|
|
2221
|
+
discount : {
|
|
2222
|
+
required : true,
|
|
2223
|
+
shape : {
|
|
2224
|
+
id : { required : true, type : 'string' }
|
|
2225
|
+
},
|
|
2226
|
+
type : 'object'
|
|
2227
|
+
}
|
|
1058
2228
|
},
|
|
1059
|
-
|
|
1060
|
-
|
|
2229
|
+
triggers : [ 'lead.insert' ],
|
|
2230
|
+
usage : { actions : 1 }
|
|
2231
|
+
})
|
|
1061
2232
|
},
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
system
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
key : 'Shopify Token Exchange',
|
|
1098
|
-
queue : 'connection',
|
|
1099
|
-
system : true
|
|
2233
|
+
|
|
2234
|
+
customer : {
|
|
2235
|
+
insert : () => ({
|
|
2236
|
+
hook : 'commerce.customer',
|
|
2237
|
+
key : 'Create customer',
|
|
2238
|
+
queue : 'connection',
|
|
2239
|
+
settings : {},
|
|
2240
|
+
triggers : [ 'lead.insert' ],
|
|
2241
|
+
usage : { actions : 1 }
|
|
2242
|
+
})
|
|
2243
|
+
},
|
|
2244
|
+
|
|
2245
|
+
// SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
|
|
2246
|
+
// in the builder, so they carry no trigger and no usage. Declared because
|
|
2247
|
+
// the routing table and the system-workflow descriptions both read here.
|
|
2248
|
+
order : {
|
|
2249
|
+
record : () => ({
|
|
2250
|
+
description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
|
|
2251
|
+
hook : 'commerce.order',
|
|
2252
|
+
key : 'Shopify Order Tracking',
|
|
2253
|
+
queue : 'connection',
|
|
2254
|
+
system : true
|
|
2255
|
+
})
|
|
2256
|
+
},
|
|
2257
|
+
|
|
2258
|
+
product : {
|
|
2259
|
+
sync : () => ({
|
|
2260
|
+
description : 'Syncs Shopify product data on webhook updates.',
|
|
2261
|
+
hook : 'commerce.product',
|
|
2262
|
+
key : 'Shopify Product Sync',
|
|
2263
|
+
queue : 'connection',
|
|
2264
|
+
system : true
|
|
2265
|
+
})
|
|
2266
|
+
}
|
|
2267
|
+
|
|
1100
2268
|
},
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
2269
|
+
|
|
2270
|
+
connection : {
|
|
2271
|
+
|
|
2272
|
+
// Not a webhook monitor, despite the name it once carried. Webhooks are
|
|
2273
|
+
// declarative — declared in the app's toml, applied by Shopify to every
|
|
2274
|
+
// install — so nothing registers or checks them here. This rotates the
|
|
2275
|
+
// access token before Shopify's idle window closes, and reconciles the
|
|
2276
|
+
// scopes the store granted against the ones the app now needs.
|
|
2277
|
+
health : {
|
|
2278
|
+
check : () => ({
|
|
2279
|
+
description : 'Keeps store access working — refreshes the access token before it goes stale and reports when the store\'s approved permissions fall behind.',
|
|
2280
|
+
hook : 'lifecycle.health',
|
|
2281
|
+
key : 'Shopify Connection Health',
|
|
2282
|
+
queue : 'connection',
|
|
2283
|
+
system : true
|
|
2284
|
+
})
|
|
2285
|
+
},
|
|
2286
|
+
|
|
2287
|
+
// Audit-only. The "Shopify Token Activity" system workflow lists these
|
|
2288
|
+
// for descriptive grouping, but its audit step docs are written manually
|
|
2289
|
+
// at OAuth time — the workflow is never dispatched. Routing is declared
|
|
2290
|
+
// defensively so that if it ever IS dispatched, the job lands on a real
|
|
2291
|
+
// queue and the handler lookup misses cleanly instead of throwing
|
|
2292
|
+
// "Unknown step type".
|
|
2293
|
+
token : {
|
|
2294
|
+
exchange : () => ({
|
|
2295
|
+
description : 'Records the token exchange that completed an install. Audit only — never dispatched.',
|
|
2296
|
+
key : 'Shopify Token Exchange',
|
|
2297
|
+
queue : 'connection',
|
|
2298
|
+
system : true
|
|
2299
|
+
}),
|
|
2300
|
+
refresh : () => ({
|
|
2301
|
+
description : 'Records a token rotation. Audit only — never dispatched.',
|
|
2302
|
+
key : 'Shopify Token Refresh',
|
|
2303
|
+
queue : 'connection',
|
|
2304
|
+
system : true
|
|
2305
|
+
})
|
|
2306
|
+
}
|
|
2307
|
+
|
|
1105
2308
|
}
|
|
2309
|
+
|
|
1106
2310
|
},
|
|
2311
|
+
// Shopify sits pending between the install landing and the merchant choosing a
|
|
2312
|
+
// plan, and nothing on our side can move it — so the card says what they need
|
|
2313
|
+
// to go and do rather than showing Pending with no next step.
|
|
2314
|
+
//
|
|
2315
|
+
// Scope drift is NOT here: drawbridge-sync writes it onto the connection
|
|
2316
|
+
// document, and the document's own warnings render beside these.
|
|
2317
|
+
tasks : ( data ) => ( data?.status === 'pending'
|
|
2318
|
+
? [
|
|
2319
|
+
{
|
|
2320
|
+
message : 'Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.',
|
|
2321
|
+
title : 'Choose a plan in Shopify'
|
|
2322
|
+
}
|
|
2323
|
+
]
|
|
2324
|
+
: []
|
|
2325
|
+
),
|
|
1107
2326
|
title : 'Shopify'
|
|
1108
2327
|
};
|
|
1109
2328
|
|
|
1110
|
-
// Drawbridge, exported from the brand kit and left as authored — the fills are the
|
|
1111
|
-
// vendor's own mark, not a recolour.
|
|
1112
|
-
//
|
|
1113
|
-
// A .js wrapper around otherwise untouched SVG so `node --test` can run against
|
|
1114
|
-
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
1115
|
-
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
1116
|
-
var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1117
|
-
<rect width="500" height="500" fill="#BAEC5F"/>
|
|
1118
|
-
<g clip-path="url(#clip0_2115_2832)">
|
|
1119
|
-
<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"/>
|
|
1120
|
-
<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"/>
|
|
1121
|
-
</g>
|
|
1122
|
-
<defs>
|
|
1123
|
-
<clipPath id="clip0_2115_2832">
|
|
1124
|
-
<rect width="220" height="284" fill="white" transform="translate(140 108)"/>
|
|
1125
|
-
</clipPath>
|
|
1126
|
-
</defs>
|
|
1127
|
-
</svg>`;
|
|
1128
|
-
|
|
1129
2329
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
1130
2330
|
// generates a signing secret rather than asking for a credential, which is why
|
|
1131
2331
|
// its one field declares no `input`.
|
|
@@ -1141,13 +2341,26 @@ var webhook = {
|
|
|
1141
2341
|
auth : {
|
|
1142
2342
|
type : 'generated'
|
|
1143
2343
|
},
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
2344
|
+
// EVERYTHING A MERCHANT READS. `errors` would belong here too — the
|
|
2345
|
+
// connection DOCUMENT carries its own `errors` array and is spread OVER the
|
|
2346
|
+
// resolved manifest downstream, so a top-level one is replaced by that array.
|
|
2347
|
+
content : {
|
|
2348
|
+
confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
|
|
2349
|
+
description : [
|
|
2350
|
+
'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
|
|
2351
|
+
'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.'
|
|
2352
|
+
],
|
|
2353
|
+
excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
|
|
2354
|
+
guide : [
|
|
2355
|
+
'Press Connect. Drawbridge generates a signing secret and shows it here.',
|
|
2356
|
+
'Copy the secret into your own endpoint.',
|
|
2357
|
+
'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.'
|
|
2358
|
+
]
|
|
2359
|
+
},
|
|
2360
|
+
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
2361
|
+
// merchant with two endpoints is a step-level choice rather than a second
|
|
2362
|
+
// connection.
|
|
2363
|
+
exclusive : false,
|
|
1151
2364
|
feature : 'organization:connection:webhook',
|
|
1152
2365
|
fields : [
|
|
1153
2366
|
{
|
|
@@ -1160,14 +2373,91 @@ var webhook = {
|
|
|
1160
2373
|
label : 'Signing secret'
|
|
1161
2374
|
}
|
|
1162
2375
|
],
|
|
2376
|
+
group : 'developer',
|
|
2377
|
+
// OUTBOUND ONLY. inbound.* is false because the direction is the point: we
|
|
2378
|
+
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
2379
|
+
// false follows from there being no third party to authenticate against —
|
|
2380
|
+
// connect generates a secret rather than proving a credential.
|
|
2381
|
+
hooks : {
|
|
2382
|
+
auth : {
|
|
2383
|
+
// Minting and clearing a secret needs no vendor call, so the api's own
|
|
2384
|
+
// handler does both.
|
|
2385
|
+
connect : {},
|
|
2386
|
+
disconnect : {},
|
|
2387
|
+
probe : false,
|
|
2388
|
+
scopes : false,
|
|
2389
|
+
// Nothing to mint. Connecting generates a secret; there is no vendor.
|
|
2390
|
+
token : false
|
|
2391
|
+
},
|
|
2392
|
+
commerce : false,
|
|
2393
|
+
contacts : { remove : false, sync : false },
|
|
2394
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2395
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2396
|
+
// these would be a second sender, which is the arrangement the platform
|
|
2397
|
+
// sender replaced.
|
|
2398
|
+
email : false,
|
|
2399
|
+
segment : false,
|
|
2400
|
+
sms : false,
|
|
2401
|
+
inbound : false,
|
|
2402
|
+
lifecycle : false,
|
|
2403
|
+
resources : {
|
|
2404
|
+
audiences : false,
|
|
2405
|
+
prices : false,
|
|
2406
|
+
products : false,
|
|
2407
|
+
promotions : false
|
|
2408
|
+
},
|
|
2409
|
+
// THE BODY IS HERE, not in drawbridge-sync. It needs `crypto` and an HTTP
|
|
2410
|
+
// client and nothing else — no controller, no queue, no database — so
|
|
2411
|
+
// there was never a reason for it to live in another repo.
|
|
2412
|
+
//
|
|
2413
|
+
// That is the rule the whole split runs on: a hook lives in sync only if it
|
|
2414
|
+
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2415
|
+
webhook : {
|
|
2416
|
+
|
|
2417
|
+
send : async ({ context, controller, request : send = safeRequest, settings, step }) => {
|
|
2418
|
+
|
|
2419
|
+
const { headers = {}, method = 'POST', url } = step.settings || {};
|
|
2420
|
+
|
|
2421
|
+
const request = { method, url : url || null };
|
|
2422
|
+
|
|
2423
|
+
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
2424
|
+
|
|
2425
|
+
// The LEAD, when there is one, rather than the accumulated context. A
|
|
2426
|
+
// receiver wants the entrant's record, not our internal step state.
|
|
2427
|
+
const lead = context?.lead
|
|
2428
|
+
? await controller.get({ collection : 'lead', query : { id : context.lead } })
|
|
2429
|
+
: null;
|
|
2430
|
+
|
|
2431
|
+
const body = lead || context;
|
|
2432
|
+
|
|
2433
|
+
request.body = body;
|
|
2434
|
+
|
|
2435
|
+
const outgoing = { ...headers };
|
|
2436
|
+
|
|
2437
|
+
// Signed with the connection's secret when it has one. A payload nobody can
|
|
2438
|
+
// verify is an unsigned payload, which is why the connection's own task card
|
|
2439
|
+
// tells the merchant to check it.
|
|
2440
|
+
if( settings?.secret ){
|
|
2441
|
+
|
|
2442
|
+
outgoing[ 'X-Drawbridge-Signature' ] = 'sha256=' + crypto
|
|
2443
|
+
.createHmac( 'sha256', settings.secret )
|
|
2444
|
+
.update( JSON.stringify( body ) )
|
|
2445
|
+
.digest( 'hex' );
|
|
2446
|
+
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const response = await send({ body, headers : outgoing, method, url });
|
|
2450
|
+
|
|
2451
|
+
return { message : 'Webhook POSTed to ' + url + '.', request, response : response || { delivered : true } };
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
},
|
|
1163
2457
|
// Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
|
|
1164
2458
|
// It is the one card that reads wrong — our logo among vendor logos — and it
|
|
1165
2459
|
// wants a mark of its own when there is one.
|
|
1166
|
-
icon,
|
|
1167
|
-
// The destination url is supplied per step, not per connection, so there is
|
|
1168
|
-
// nothing to finish here — generating the secret IS connecting.
|
|
1169
|
-
incomplete : () => null,
|
|
1170
|
-
label : 'webhook',
|
|
2460
|
+
icon: icon$3,
|
|
1171
2461
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
1172
2462
|
// stored safely, so the connection must not be offered at all.
|
|
1173
2463
|
requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
|
|
@@ -1175,39 +2465,28 @@ var webhook = {
|
|
|
1175
2465
|
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
1176
2466
|
// false follows from there being no third party to authenticate against —
|
|
1177
2467
|
// connect generates a secret rather than proving a credential.
|
|
1178
|
-
supports : {
|
|
1179
|
-
'auth.connect' : true,
|
|
1180
|
-
'auth.disconnect' : true,
|
|
1181
|
-
'auth.probe' : false,
|
|
1182
|
-
'auth.scopes' : false,
|
|
1183
|
-
'catalog.audiences' : false,
|
|
1184
|
-
'catalog.prices' : false,
|
|
1185
|
-
'catalog.products' : false,
|
|
1186
|
-
'catalog.promotions' : false,
|
|
1187
|
-
'inbound.event' : false,
|
|
1188
|
-
'inbound.process' : false,
|
|
1189
|
-
'inbound.receive' : false,
|
|
1190
|
-
'inbound.verify' : false,
|
|
1191
|
-
'lifecycle.cleanup' : false,
|
|
1192
|
-
'lifecycle.register' : false,
|
|
1193
|
-
'lifecycle.rehydrate' : false
|
|
1194
|
-
},
|
|
1195
|
-
setup : [
|
|
1196
|
-
'Press Connect. Drawbridge generates a signing secret and shows it here.',
|
|
1197
|
-
'Copy the secret into your own endpoint.',
|
|
1198
|
-
'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.'
|
|
1199
|
-
],
|
|
1200
2468
|
slug : 'webhook',
|
|
2469
|
+
// The destination url is supplied per step, not per connection, so there is
|
|
2470
|
+
// nothing to finish here — generating the secret IS connecting, and no
|
|
2471
|
+
// setting can make this connection unusable. The credential's own verdict
|
|
2472
|
+
// stands.
|
|
2473
|
+
status : ( data ) => data?.status,
|
|
1201
2474
|
steps : {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
2475
|
+
webhook : {
|
|
2476
|
+
send : () => ({
|
|
2477
|
+
hook : 'webhook.send',
|
|
2478
|
+
key : 'Send webhook',
|
|
2479
|
+
queue : 'webhook',
|
|
2480
|
+
settings : {
|
|
2481
|
+
url : { format : 'url', required : true, type : 'string' }
|
|
2482
|
+
},
|
|
2483
|
+
triggers : [ 'lead.insert', 'lead.delete' ],
|
|
2484
|
+
// Replaces `billable : true`, which fed BILLABLE_STEP_TYPES, which set
|
|
2485
|
+
// workflow.billable at save, which sync then checked against a usage
|
|
2486
|
+
// the handler returned — three hops for one fact, two of which could
|
|
2487
|
+
// disagree silently.
|
|
2488
|
+
usage : { actions : 1 }
|
|
2489
|
+
})
|
|
1211
2490
|
}
|
|
1212
2491
|
},
|
|
1213
2492
|
// The card the connection page raises. Before connecting it explains what
|
|
@@ -1257,9 +2536,30 @@ var webhook = {
|
|
|
1257
2536
|
|
|
1258
2537
|
// The queues sync runs. A step routes to exactly one, and naming them here is
|
|
1259
2538
|
// what lets sync derive its routing table instead of maintaining a parallel copy
|
|
1260
|
-
// that can fall out of step with the
|
|
2539
|
+
// that can fall out of step with the resources.
|
|
1261
2540
|
const QUEUES = [ 'connection', 'notification', 'segment', 'webhook' ];
|
|
1262
2541
|
|
|
2542
|
+
// A HOOK'S VALUE IS ITS ANSWER — false is a decision recorded, a function is the
|
|
2543
|
+
// body, and {} means supported but implemented in the repo holding the vendor's
|
|
2544
|
+
// dependencies. Anything else, including absence, is not implemented.
|
|
2545
|
+
const implemented = ( hooks, path ) => {
|
|
2546
|
+
|
|
2547
|
+
const hook = path.split( '.' ).reduce( ( node, key ) => node?.[ key ], hooks );
|
|
2548
|
+
|
|
2549
|
+
return typeof hook === 'function' || ( !! hook && typeof hook === 'object' );
|
|
2550
|
+
|
|
2551
|
+
};
|
|
2552
|
+
|
|
2553
|
+
// Walk a nested map to its FUNCTION leaves, returning [ 'a.b.c', fn ] pairs.
|
|
2554
|
+
// Depth is whatever the vendor wrote, because stored step types run three and
|
|
2555
|
+
// four segments deep and those strings cannot be renamed without a backfill.
|
|
2556
|
+
const leaves = ( node, path = [] ) => Object.entries( node || {} ).flatMap(
|
|
2557
|
+
( [ key, value ] ) => ( typeof value === 'function'
|
|
2558
|
+
? [ [ [ ...path, key ].join( '.' ), value ] ]
|
|
2559
|
+
: value && typeof value === 'object' ? leaves( value, [ ...path, key ] ) : []
|
|
2560
|
+
)
|
|
2561
|
+
);
|
|
2562
|
+
|
|
1263
2563
|
// WHAT A CONNECTION MUST DECLARE. Thrown at import rather than discovered by a
|
|
1264
2564
|
// merchant looking at a broken card, or by a workflow that accepted a step it
|
|
1265
2565
|
// could never run.
|
|
@@ -1267,12 +2567,24 @@ const build = ( manifest ) => {
|
|
|
1267
2567
|
|
|
1268
2568
|
if( ! manifest?.slug ) throw new Error( 'A connection needs a slug' );
|
|
1269
2569
|
if( ! manifest?.title ) throw new Error( manifest.slug + ' needs a title' );
|
|
1270
|
-
|
|
1271
|
-
|
|
2570
|
+
// A PRIVATE connection is Drawbridge itself: always present, in every
|
|
2571
|
+
// deployment, for every organization. It contributes steps and never appears
|
|
2572
|
+
// in the catalog, so the two questions a public connection answers — can this
|
|
2573
|
+
// merchant connect it (`requires`) and does their plan allow it (`feature`) —
|
|
2574
|
+
// have no meaning and are not asked.
|
|
2575
|
+
if( ! manifest?.private && ! manifest?.feature ) throw new Error( manifest.slug + ' needs a plan feature key' );
|
|
2576
|
+
// EVERYTHING A MERCHANT READS lives under `content`, so the question on a new
|
|
2577
|
+
// vendor is "does a person read this" rather than "is this marketing".
|
|
2578
|
+
//
|
|
2579
|
+
// `errors` is in there for a harder reason than tidiness: the connection
|
|
2580
|
+
// DOCUMENT carries its own `errors` array, and the document is spread OVER the
|
|
2581
|
+
// resolved manifest downstream — a top-level `errors` here would be replaced
|
|
2582
|
+
// by that array and never render.
|
|
2583
|
+
if( ! manifest?.content?.excerpt ) throw new Error( manifest.slug + ' needs content.excerpt for its card' );
|
|
1272
2584
|
|
|
1273
|
-
if( ! Array.isArray( manifest?.description ) || ! manifest.description.length ){
|
|
2585
|
+
if( ! Array.isArray( manifest?.content?.description ) || ! manifest.content.description.length ){
|
|
1274
2586
|
|
|
1275
|
-
throw new Error( manifest.slug + ' needs
|
|
2587
|
+
throw new Error( manifest.slug + ' needs content.description — an array of paragraphs for its page' );
|
|
1276
2588
|
|
|
1277
2589
|
}
|
|
1278
2590
|
|
|
@@ -1300,26 +2612,46 @@ const build = ( manifest ) => {
|
|
|
1300
2612
|
// merchant, at form time. A Klaviyo list cannot be a static list here —
|
|
1301
2613
|
// it exists in their account, not in this package — and the alternative is
|
|
1302
2614
|
// asking a merchant to paste an id from another browser tab.
|
|
1303
|
-
if( field.input === 'select' && ! ( field.options || [] ).length && ! field.
|
|
2615
|
+
if( field.input === 'select' && ! ( field.options || [] ).length && ! field.hook ){
|
|
1304
2616
|
|
|
1305
|
-
throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a
|
|
2617
|
+
throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a hook' );
|
|
1306
2618
|
|
|
1307
2619
|
}
|
|
1308
2620
|
|
|
1309
|
-
// A
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
2621
|
+
// A HOOK, named outright. ListResource and InputResource take an
|
|
2622
|
+
// `endpoint`, and the client composes one from this — building a path from
|
|
2623
|
+
// a name, rather than the manifest carrying the api's route shape. That
|
|
2624
|
+
// route has already moved twice; a path here would ship the old one to
|
|
2625
|
+
// five repos that upgrade at different times.
|
|
2626
|
+
//
|
|
2627
|
+
// It also lifts a limit nobody decided: a connection needing a list AND a
|
|
2628
|
+
// segment is two fields with two hooks, where one `source` per form
|
|
2629
|
+
// quietly loaded only the first.
|
|
2630
|
+
if( field.hook ){
|
|
2631
|
+
|
|
2632
|
+
if( ! HOOK_NAMES.includes( field.hook ) ){
|
|
2633
|
+
|
|
2634
|
+
throw new Error( manifest.slug + '.' + field.key + ' names an unknown hook: ' + field.hook );
|
|
2635
|
+
|
|
2636
|
+
}
|
|
1313
2637
|
|
|
1314
|
-
|
|
2638
|
+
// The picker route is a GET, and a GET must be safe to repeat — Next
|
|
2639
|
+
// prefetches, proxies retry. `resources.*` is the read-only domain, so a
|
|
2640
|
+
// field cannot point a dropdown at auth.disconnect. `supports` alone
|
|
2641
|
+
// would not catch that: Klaviyo really does support auth.disconnect.
|
|
2642
|
+
if( ! field.hook.startsWith( 'resources.' ) ){
|
|
1315
2643
|
|
|
1316
|
-
throw new Error( manifest.slug + '.' + field.key + '
|
|
2644
|
+
throw new Error( manifest.slug + '.' + field.key + ' reads from ' + field.hook + ' — a picker may only read resources.*' );
|
|
1317
2645
|
|
|
1318
2646
|
}
|
|
1319
2647
|
|
|
1320
|
-
|
|
2648
|
+
// ASK THE HOOK ITSELF. This used to consult a parallel `supports` map,
|
|
2649
|
+
// which is two places to say one thing and therefore two places to
|
|
2650
|
+
// disagree — a picker pointed at a hook the vendor had since removed
|
|
2651
|
+
// still passed, because the map still said true.
|
|
2652
|
+
if( ! implemented( manifest.hooks, field.hook ) ){
|
|
1321
2653
|
|
|
1322
|
-
throw new Error( manifest.slug + '.' + field.key + '
|
|
2654
|
+
throw new Error( manifest.slug + '.' + field.key + ' reads ' + field.hook + ', which this vendor does not implement' );
|
|
1323
2655
|
|
|
1324
2656
|
}
|
|
1325
2657
|
|
|
@@ -1355,9 +2687,12 @@ const build = ( manifest ) => {
|
|
|
1355
2687
|
|
|
1356
2688
|
}
|
|
1357
2689
|
|
|
1358
|
-
|
|
2690
|
+
// `group` does the actual grouping on the connections page, and `category`
|
|
2691
|
+
// was a second word for the same fact — so it is gone rather than kept as an
|
|
2692
|
+
// alias nobody could tell apart from this one.
|
|
2693
|
+
if( ! GROUPS.includes( manifest?.group ) ){
|
|
1359
2694
|
|
|
1360
|
-
throw new Error( manifest.slug + ' needs a
|
|
2695
|
+
throw new Error( manifest.slug + ' needs a group — one of ' + GROUPS.join( ', ' ) );
|
|
1361
2696
|
|
|
1362
2697
|
}
|
|
1363
2698
|
|
|
@@ -1392,6 +2727,25 @@ const build = ( manifest ) => {
|
|
|
1392
2727
|
|
|
1393
2728
|
}
|
|
1394
2729
|
|
|
2730
|
+
// THE TOKEN REQUEST IS A HOOK, and an oauth vendor that does not implement
|
|
2731
|
+
// it cannot mint anything. Refused here rather than discovered at the
|
|
2732
|
+
// callback, which is after the merchant has consented and left.
|
|
2733
|
+
if( typeof manifest.hooks?.auth?.token !== 'function' ){
|
|
2734
|
+
|
|
2735
|
+
throw new Error( manifest.slug + ' is oauth and must implement hooks.auth.token — point it at authToken() or wrap it' );
|
|
2736
|
+
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
for( const url of OAUTH_URLS ){
|
|
2740
|
+
|
|
2741
|
+
if( ! manifest.auth.oauth?.urls?.[ url ] ){
|
|
2742
|
+
|
|
2743
|
+
throw new Error( manifest.slug + ' is oauth and must declare auth.oauth.urls.' + url );
|
|
2744
|
+
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
}
|
|
2748
|
+
|
|
1395
2749
|
// THE REDIRECT MUST BE THE ONE PATH THE DASHBOARD SERVES.
|
|
1396
2750
|
//
|
|
1397
2751
|
// There is exactly one callback route — app/api/connection/[slug]/callback
|
|
@@ -1404,10 +2758,10 @@ const build = ( manifest ) => {
|
|
|
1404
2758
|
// registered at the vendor, and fails AFTER the merchant has consented —
|
|
1405
2759
|
// they hit a dashboard 404 having already granted access. Cheaper to refuse
|
|
1406
2760
|
// the manifest at import.
|
|
1407
|
-
if( manifest.auth.oauth.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
|
|
2761
|
+
if( manifest.auth.oauth.urls.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
|
|
1408
2762
|
|
|
1409
2763
|
throw new Error(
|
|
1410
|
-
manifest.slug + ' declares auth.oauth.redirect ' + manifest.auth.oauth.redirect
|
|
2764
|
+
manifest.slug + ' declares auth.oauth.urls.redirect ' + manifest.auth.oauth.urls.redirect
|
|
1411
2765
|
+ ' but the only callback route is /api/connection/' + manifest.slug + '/callback'
|
|
1412
2766
|
);
|
|
1413
2767
|
|
|
@@ -1418,15 +2772,15 @@ const build = ( manifest ) => {
|
|
|
1418
2772
|
// A vendor that receives from the outside must say where it puts the event
|
|
1419
2773
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
1420
2774
|
// is a request accepted and dropped rather than an error.
|
|
1421
|
-
if( manifest.
|
|
2775
|
+
if( implemented( manifest.hooks, 'inbound.event' ) && ! manifest.inbound?.headers?.event ){
|
|
1422
2776
|
|
|
1423
|
-
throw new Error( manifest.slug + '
|
|
2777
|
+
throw new Error( manifest.slug + ' implements inbound.event but declares no inbound.headers.event' );
|
|
1424
2778
|
|
|
1425
2779
|
}
|
|
1426
2780
|
|
|
1427
|
-
if( manifest.
|
|
2781
|
+
if( implemented( manifest.hooks, 'inbound.verify' ) && ! manifest.inbound?.headers?.signature ){
|
|
1428
2782
|
|
|
1429
|
-
throw new Error( manifest.slug + '
|
|
2783
|
+
throw new Error( manifest.slug + ' implements inbound.verify but declares no inbound.headers.signature' );
|
|
1430
2784
|
|
|
1431
2785
|
}
|
|
1432
2786
|
|
|
@@ -1445,101 +2799,125 @@ const build = ( manifest ) => {
|
|
|
1445
2799
|
// Required on every manifest for the same reason `supports` is: a missing
|
|
1446
2800
|
// answer is indistinguishable from a vendor written before the question
|
|
1447
2801
|
// existed.
|
|
1448
|
-
if( typeof manifest?.
|
|
2802
|
+
if( typeof manifest?.status !== 'function' ){
|
|
1449
2803
|
|
|
1450
|
-
throw new Error( manifest.slug + ' must declare
|
|
2804
|
+
throw new Error( manifest.slug + ' must declare status( data ) — return null to accept the connection\'s own status, or { message, status } to override it' );
|
|
1451
2805
|
|
|
1452
2806
|
}
|
|
1453
2807
|
|
|
1454
|
-
// How to
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
if( ! Array.isArray( manifest?.
|
|
2808
|
+
// How to connect, in the merchant's words. A connection page that cannot say
|
|
2809
|
+
// where to find an API key sends somebody to a vendor's docs written for a
|
|
2810
|
+
// different integration.
|
|
2811
|
+
if( ! Array.isArray( manifest?.content?.guide ) || ! manifest.content.guide.length ){
|
|
1458
2812
|
|
|
1459
|
-
throw new Error( manifest.slug + ' needs
|
|
2813
|
+
throw new Error( manifest.slug + ' needs content.guide — an array of steps for its page' );
|
|
1460
2814
|
|
|
1461
2815
|
}
|
|
1462
2816
|
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
|
|
1468
|
-
// auth.connect is the one that matters most: it is the only vendor-specific
|
|
1469
|
-
// step in an OAuth callback, so putting it here is what lets ONE route serve
|
|
1470
|
-
// every OAuth vendor instead of one route each.
|
|
1471
|
-
//
|
|
1472
|
-
// Keyed flat ('auth.connect') to match the vocabulary and the supports map,
|
|
1473
|
-
// so the three cannot drift apart in naming.
|
|
1474
|
-
for( const [ name, hook ] of Object.entries( manifest.hooks || {} ) ){
|
|
2817
|
+
// A STATUS THE SCHEMA WILL ACCEPT. Called with nothing, which is the catalog
|
|
2818
|
+
// case — a vendor nobody has connected — so undefined is a legitimate answer.
|
|
2819
|
+
// Anything else must be one of the four, because a typo here surfaces as a
|
|
2820
|
+
// Mongo "Document failed validation" with no clue which field caused it.
|
|
2821
|
+
if( typeof manifest?.status !== 'function' ){
|
|
1475
2822
|
|
|
1476
|
-
|
|
2823
|
+
throw new Error( manifest.slug + ' must declare status( data ) — one of ' + STATUSES.join( ', ' ) );
|
|
1477
2824
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
}
|
|
2825
|
+
}
|
|
1481
2826
|
|
|
1482
|
-
|
|
2827
|
+
const status = manifest.status({});
|
|
1483
2828
|
|
|
1484
|
-
|
|
2829
|
+
if( status != null && ! STATUSES.includes( status ) ){
|
|
1485
2830
|
|
|
1486
|
-
|
|
2831
|
+
throw new Error( manifest.slug + ' status() returned ' + status + ' — must be one of ' + STATUSES.join( ', ' ) );
|
|
1487
2832
|
|
|
1488
|
-
|
|
1489
|
-
// lying, and the matrix is what a caller reads to decide whether to
|
|
1490
|
-
// bother asking. Caught here rather than discovered as a hook that is
|
|
1491
|
-
// never called.
|
|
1492
|
-
if( manifest.supports?.[ name ] !== true ){
|
|
2833
|
+
}
|
|
1493
2834
|
|
|
1494
|
-
|
|
2835
|
+
// THE UNIFORM SURFACE — every connection answers every hook in the vocabulary,
|
|
2836
|
+
// and the ANSWER IS THE VALUE. There is no `supports` map any more: it was a
|
|
2837
|
+
// second place to say the same thing, so a manifest could implement a hook it
|
|
2838
|
+
// declared unsupported, or claim one it had since deleted, and build() had two
|
|
2839
|
+
// separate checks trying to keep them honest.
|
|
2840
|
+
//
|
|
2841
|
+
// false declined — a decision recorded, not silence
|
|
2842
|
+
// function supported, and the body is right here
|
|
2843
|
+
// {} supported, implemented in the repo holding the dependencies
|
|
2844
|
+
//
|
|
2845
|
+
// A missing entry is rejected rather than defaulted, because a default is the
|
|
2846
|
+
// silence this exists to remove: it would be impossible to tell a vendor that
|
|
2847
|
+
// declined a hook from one written before the hook existed.
|
|
2848
|
+
for( const [ domain, verbs ] of Object.entries( HOOKS ) ){
|
|
1495
2849
|
|
|
1496
|
-
|
|
2850
|
+
for( const verb of verbs ){
|
|
1497
2851
|
|
|
1498
|
-
|
|
2852
|
+
const hook = manifest.hooks?.[ domain ]?.[ verb ];
|
|
1499
2853
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
// exactly the silence this exists to remove: it would be impossible to tell a
|
|
1504
|
-
// vendor that declined a hook from one written before the hook existed.
|
|
1505
|
-
const supports = manifest.supports || {};
|
|
2854
|
+
// A domain answered wholesale — `inbound : false` rather than four
|
|
2855
|
+
// falses — is how a vendor that receives nothing says so once.
|
|
2856
|
+
if( manifest.hooks?.[ domain ] === false ) continue;
|
|
1506
2857
|
|
|
1507
|
-
|
|
2858
|
+
if( hook !== false && ! implemented( { [ domain ] : { [ verb ] : hook } }, domain + '.' + verb ) ){
|
|
1508
2859
|
|
|
1509
|
-
|
|
2860
|
+
throw new Error( manifest.slug + ' must answer hooks.' + domain + '.' + verb + ' — false, a function, or {} if another repo implements it' );
|
|
1510
2861
|
|
|
1511
|
-
|
|
2862
|
+
}
|
|
1512
2863
|
|
|
1513
2864
|
}
|
|
1514
2865
|
|
|
1515
2866
|
}
|
|
1516
2867
|
|
|
1517
|
-
for( const
|
|
2868
|
+
for( const [ domain, verbs ] of Object.entries( manifest.hooks || {} ) ){
|
|
2869
|
+
|
|
2870
|
+
if( ! HOOKS[ domain ] ) throw new Error( manifest.slug + ' implements an unknown hook domain: ' + domain );
|
|
1518
2871
|
|
|
1519
|
-
|
|
2872
|
+
for( const verb of Object.keys( verbs === false ? {} : verbs ) ){
|
|
1520
2873
|
|
|
1521
|
-
|
|
2874
|
+
if( ! HOOKS[ domain ].includes( verb ) ){
|
|
2875
|
+
|
|
2876
|
+
throw new Error( manifest.slug + ' implements an unknown hook: ' + domain + '.' + verb );
|
|
2877
|
+
|
|
2878
|
+
}
|
|
1522
2879
|
|
|
1523
2880
|
}
|
|
1524
2881
|
|
|
1525
2882
|
}
|
|
1526
2883
|
|
|
1527
|
-
|
|
2884
|
+
// STEPS ARE NESTED FOR READABILITY and flattened to their stored name. Each
|
|
2885
|
+
// leaf is a FUNCTION — a step can depend on what the merchant's connection
|
|
2886
|
+
// knows, which a static object would have to be true for at publish time.
|
|
2887
|
+
for( const [ name, step ] of leaves( manifest.steps ) ){
|
|
2888
|
+
|
|
2889
|
+
const type = 'step.' + name;
|
|
1528
2890
|
|
|
1529
|
-
|
|
2891
|
+
// A step nobody added to the vocabulary is a workflow document the database
|
|
2892
|
+
// will refuse — and refuse naming no field. Said here instead, at import,
|
|
2893
|
+
// where the file that caused it is obvious.
|
|
2894
|
+
if( ! STEPS[ name ] ){
|
|
1530
2895
|
|
|
1531
|
-
throw new Error( manifest.slug + ' declares
|
|
2896
|
+
throw new Error( manifest.slug + ' declares an unknown step: ' + type + ' — add it to STEPS in contract.js' );
|
|
1532
2897
|
|
|
1533
2898
|
}
|
|
1534
2899
|
|
|
1535
|
-
|
|
2900
|
+
// Called with nothing, which is what a step must tolerate: the builder lists
|
|
2901
|
+
// steps for a merchant who has not chosen a connection yet.
|
|
2902
|
+
const declared = step({});
|
|
1536
2903
|
|
|
1537
|
-
if( !
|
|
2904
|
+
if( ! declared?.key ) throw new Error( manifest.slug + ' step ' + type + ' needs a key — the label the builder shows' );
|
|
2905
|
+
|
|
2906
|
+
if( ! QUEUES.includes( declared?.queue ) ){
|
|
1538
2907
|
|
|
1539
2908
|
throw new Error( manifest.slug + ' step ' + type + ' needs a queue — one of ' + QUEUES.join( ', ' ) );
|
|
1540
2909
|
|
|
1541
2910
|
}
|
|
1542
2911
|
|
|
2912
|
+
// A step pointing at a hook this vendor does not implement is a workflow
|
|
2913
|
+
// that accepts the step and then silently does nothing. System steps carry
|
|
2914
|
+
// no hook: drawbridge-sync dispatches them and owns their handlers.
|
|
2915
|
+
if( declared.hook && ! implemented( manifest.hooks, declared.hook ) ){
|
|
2916
|
+
|
|
2917
|
+
throw new Error( manifest.slug + ' step ' + type + ' points at hook ' + declared.hook + ', which this vendor does not implement' );
|
|
2918
|
+
|
|
2919
|
+
}
|
|
2920
|
+
|
|
1543
2921
|
}
|
|
1544
2922
|
|
|
1545
2923
|
return Object.freeze({
|
|
@@ -1547,15 +2925,28 @@ const build = ( manifest ) => {
|
|
|
1547
2925
|
fields : Object.freeze( manifest.fields || [] ),
|
|
1548
2926
|
hooks : Object.freeze( manifest.hooks || {} ),
|
|
1549
2927
|
inbound : Object.freeze( manifest.inbound || {} ),
|
|
1550
|
-
setup : Object.freeze( manifest.setup || [] ),
|
|
1551
|
-
supports : Object.freeze( supports ),
|
|
1552
2928
|
requires : Object.freeze( manifest.requires || [] ),
|
|
1553
2929
|
steps : Object.freeze( manifest.steps || {} )
|
|
1554
2930
|
});
|
|
1555
2931
|
|
|
1556
2932
|
};
|
|
1557
2933
|
|
|
2934
|
+
// EVERY STEP TYPE ANY VENDOR IMPLEMENTS, as { 'step.domain.verb' : label }.
|
|
2935
|
+
//
|
|
2936
|
+
// drawbridge-api's enums.step.type — the $jsonSchema validator on the workflow
|
|
2937
|
+
// collection — unions this over its own map, so adding a vendor step is a
|
|
2938
|
+
// manifest change and nothing else. It UNIONS rather than replaces: retired and
|
|
2939
|
+
// not-yet-migrated types must stay writable, or the migration that retires them
|
|
2940
|
+
// cannot write either.
|
|
2941
|
+
const stepLabels = ( catalog = connections ) => Object.fromEntries(
|
|
2942
|
+
Object.values( catalog ).flatMap(
|
|
2943
|
+
( vendor ) => leaves( vendor.steps ).map( ( [ name ] ) => [ 'step.' + name, STEP_LABELS[ 'step.' + name ] ] )
|
|
2944
|
+
)
|
|
2945
|
+
);
|
|
2946
|
+
|
|
1558
2947
|
const connections = Object.freeze({
|
|
2948
|
+
drawbridge : build( drawbridge ),
|
|
2949
|
+
hubspot : build( hubspot ),
|
|
1559
2950
|
klaviyo : build( klaviyo ),
|
|
1560
2951
|
mailchimp : build( mailchimp ),
|
|
1561
2952
|
shopify : build( shopify ),
|
|
@@ -1580,7 +2971,10 @@ const connections = Object.freeze({
|
|
|
1580
2971
|
|
|
1581
2972
|
for( const [ slug, manifest ] of Object.entries( connections ) ){
|
|
1582
2973
|
|
|
1583
|
-
for( const
|
|
2974
|
+
for( const [ name ] of leaves( manifest.steps ) ){
|
|
2975
|
+
|
|
2976
|
+
const type = 'step.' + name;
|
|
2977
|
+
|
|
1584
2978
|
|
|
1585
2979
|
if( owners[ type ] ){
|
|
1586
2980
|
|
|
@@ -1606,6 +3000,21 @@ const availableConnections = ( env = {} ) => Object.fromEntries(
|
|
|
1606
3000
|
)
|
|
1607
3001
|
);
|
|
1608
3002
|
|
|
3003
|
+
// WHAT A MERCHANT CAN SEE AND CONNECT. Everything available, minus the private
|
|
3004
|
+
// ones.
|
|
3005
|
+
//
|
|
3006
|
+
// The two lists are different questions and both are needed: `drawbridge` is
|
|
3007
|
+
// runnable everywhere and contributes the base workflow steps, so
|
|
3008
|
+
// availableConnections MUST include it — but it is not a card, has no credential
|
|
3009
|
+
// and nothing to connect, so the connections page must not offer it.
|
|
3010
|
+
//
|
|
3011
|
+
// Splitting them rather than filtering at the call site because there are two
|
|
3012
|
+
// listing sites in route/organization-connection.js, and a filter applied to one
|
|
3013
|
+
// and forgotten on the other is exactly how a private connection surfaces.
|
|
3014
|
+
const catalogConnections = ( env = {} ) => Object.fromEntries(
|
|
3015
|
+
Object.entries( availableConnections( env ) ).filter( ( [ , manifest ] ) => ! manifest.private )
|
|
3016
|
+
);
|
|
3017
|
+
|
|
1609
3018
|
// Per-slug allowlist of stored setting keys safe to return in an API response.
|
|
1610
3019
|
// DERIVED from each vendor's own field declaration — a field is public unless it
|
|
1611
3020
|
// says `redact : true`. An unknown slug gets an empty allowlist, so a leftover
|
|
@@ -1620,16 +3029,23 @@ const publicSettingsBySlug = Object.fromEntries(
|
|
|
1620
3029
|
// Every step every configured vendor contributes, flattened and stamped with the
|
|
1621
3030
|
// slug that owns it. The api builds its workflow catalog from this and sync
|
|
1622
3031
|
// builds its routing table from it, so a step cannot exist on one side only.
|
|
3032
|
+
// SHAPE PRESERVED DELIBERATELY: still { ...step, slug, type }. drawbridge-sync
|
|
3033
|
+
// derives its whole routing table from this and its step-handler coverage test
|
|
3034
|
+
// reads it, so nesting the manifests must not change what comes out — otherwise
|
|
3035
|
+
// a manifest refactor becomes a sync release.
|
|
3036
|
+
//
|
|
3037
|
+
// Each step is now a FUNCTION, called here with no connection: this is the
|
|
3038
|
+
// catalog view, the same one the builder shows before a merchant picks anything.
|
|
1623
3039
|
const connectionSteps = ( env = {} ) => Object.entries( availableConnections( env ) )
|
|
1624
|
-
.flatMap( ( [ slug, manifest ] ) =>
|
|
1625
|
-
.map( ( [
|
|
3040
|
+
.flatMap( ( [ slug, manifest ] ) => leaves( manifest.steps )
|
|
3041
|
+
.map( ( [ name, step ] ) => ({ ...step({}), slug, type : 'step.' + name }) )
|
|
1626
3042
|
);
|
|
1627
3043
|
|
|
1628
3044
|
// Which vendors implement a given hook. The uniform surface makes this total —
|
|
1629
3045
|
// every vendor appears in exactly one of the two lists, never neither.
|
|
1630
3046
|
const hookSupport = ( name ) => ({
|
|
1631
|
-
no : Object.keys( connections ).filter( ( slug ) => ! connections[ slug ].
|
|
1632
|
-
yes : Object.keys( connections ).filter( ( slug ) => connections[ slug ].
|
|
3047
|
+
no : Object.keys( connections ).filter( ( slug ) => ! implemented( connections[ slug ].hooks, name ) ),
|
|
3048
|
+
yes : Object.keys( connections ).filter( ( slug ) => implemented( connections[ slug ].hooks, name ) )
|
|
1633
3049
|
});
|
|
1634
3050
|
|
|
1635
3051
|
// A vendor's fields, described for the dashboard. Served so one generic form
|
|
@@ -1644,8 +3060,9 @@ const hookSupport = ( name ) => ({
|
|
|
1644
3060
|
// VALUE is withheld independently by whatever redacts stored settings, and a
|
|
1645
3061
|
// test asserts no descriptor ever carries one.
|
|
1646
3062
|
const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
|
|
1647
|
-
.map( ({ copy, from, input, key, label, message, options, placeholder, redact, required }) => ({
|
|
3063
|
+
.map( ({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
|
|
1648
3064
|
...( copy && { copy : true }),
|
|
3065
|
+
...( hook && { hook }),
|
|
1649
3066
|
...( from && { from }),
|
|
1650
3067
|
...( input && { input }),
|
|
1651
3068
|
key,
|
|
@@ -1653,6 +3070,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
|
|
|
1653
3070
|
...( message && { message }),
|
|
1654
3071
|
...( options && { options }),
|
|
1655
3072
|
...( placeholder && { placeholder }),
|
|
3073
|
+
// Declared false only where the vendor cannot filter, so a picker does not
|
|
3074
|
+
// offer a search box that quietly searches one page.
|
|
3075
|
+
...( search === false && { search : false }),
|
|
1656
3076
|
required : Boolean( required ),
|
|
1657
3077
|
// A UI hint, not a leak: the form uses it to stop requiring the field once
|
|
1658
3078
|
// the connection exists, and to say "leave blank to keep" — because the GET
|
|
@@ -1675,9 +3095,15 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
1675
3095
|
|
|
1676
3096
|
if( ! manifest ) return { outcome : OUTCOMES.unsupported, reason : 'no such connection: ' + slug };
|
|
1677
3097
|
|
|
1678
|
-
|
|
3098
|
+
// The hook's own value is the answer — there is no `supports` map to consult,
|
|
3099
|
+
// and therefore none to disagree with what is actually here.
|
|
3100
|
+
const hook = name.split( '.' ).reduce( ( node, key ) => node?.[ key ], manifest.hooks );
|
|
3101
|
+
|
|
3102
|
+
if( hook === false || hook == null ){
|
|
1679
3103
|
|
|
1680
|
-
|
|
3104
|
+
return { outcome : OUTCOMES.unsupported, reason : slug + ' does not implement ' + name };
|
|
3105
|
+
|
|
3106
|
+
}
|
|
1681
3107
|
|
|
1682
3108
|
// Declared supported, implemented somewhere else. Sync owns the step handlers
|
|
1683
3109
|
// and lifecycle jobs, so this is a legitimate answer here rather than a fault
|
|
@@ -1690,7 +3116,12 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
1690
3116
|
|
|
1691
3117
|
try {
|
|
1692
3118
|
|
|
1693
|
-
|
|
3119
|
+
// THE MANIFEST RIDES ALONG so a hook can read its own declared urls rather
|
|
3120
|
+
// than repeating them as literals — `revoke` was a literal in klaviyo's
|
|
3121
|
+
// disconnect until auth.oauth.urls gathered every vendor address in one
|
|
3122
|
+
// place, and a hook that cannot see its own manifest would have forced it
|
|
3123
|
+
// back. Callers never have to know to pass it.
|
|
3124
|
+
return { outcome : OUTCOMES.answered, result : await hook({ ...args, manifest }) };
|
|
1694
3125
|
|
|
1695
3126
|
} catch ( error ) {
|
|
1696
3127
|
|
|
@@ -1766,22 +3197,27 @@ const redactSettings = ({ slug, settings }) => {
|
|
|
1766
3197
|
// dropped rather than carried forward as keys that look supported.
|
|
1767
3198
|
const publicConnectionKeys = Object.freeze([
|
|
1768
3199
|
'actions',
|
|
1769
|
-
'
|
|
1770
|
-
'
|
|
3200
|
+
// API-COMPOSED, not manifest-declared: the api's resolve() builds it from
|
|
3201
|
+
// auth.type, content.redirect and the manifest's manage() — the client reads
|
|
3202
|
+
// connect.type to choose entered-vs-installed, connect.redirect for the App
|
|
3203
|
+
// Store link, connect.manage for the admin deep link. It was dropped from
|
|
3204
|
+
// this list when the manifests stopped declaring it, which stripped the
|
|
3205
|
+
// composed object from every response and broke all three.
|
|
1771
3206
|
'connect',
|
|
3207
|
+
// EVERYTHING A MERCHANT READS, in one key: confirm, description, errors,
|
|
3208
|
+
// excerpt, guide, and any vendor redirect copy.
|
|
3209
|
+
'content',
|
|
1772
3210
|
'createdAt',
|
|
3211
|
+
// The connection DOCUMENT's own errors array — scope-drift entries written by
|
|
3212
|
+
// drawbridge-sync. NOT the manifest's error copy, which is content.errors:
|
|
3213
|
+
// the document is spread OVER the resolved manifest downstream, so the two
|
|
3214
|
+
// sharing this key means the array silently wins.
|
|
1773
3215
|
'errors',
|
|
1774
|
-
'description',
|
|
1775
|
-
'excerpt',
|
|
1776
3216
|
'fields',
|
|
1777
3217
|
'group',
|
|
1778
3218
|
'id',
|
|
1779
3219
|
'image',
|
|
1780
|
-
|
|
1781
|
-
// list chosen. Public because the card that shows Pending has to say why.
|
|
1782
|
-
'incomplete',
|
|
1783
|
-
'label',
|
|
1784
|
-
'setup',
|
|
3220
|
+
|
|
1785
3221
|
'settings',
|
|
1786
3222
|
'shop',
|
|
1787
3223
|
'slug',
|
|
@@ -1821,7 +3257,12 @@ const projectConnection = ( record ) => {
|
|
|
1821
3257
|
// never reach the output under that name: the connection DOCUMENT's own
|
|
1822
3258
|
// `settings` is spread over this downstream, and two different things sharing a
|
|
1823
3259
|
// key is how a redaction quietly stops applying.
|
|
1824
|
-
|
|
3260
|
+
// `env` reaches a function field as its SECOND argument, so a manifest can name
|
|
3261
|
+
// a deployment's variables without reading process.env itself — the same reason
|
|
3262
|
+
// auth.oauth.client names them rather than holding them. A published package
|
|
3263
|
+
// that reads its consumer's environment is one that behaves differently
|
|
3264
|
+
// depending on who imported it.
|
|
3265
|
+
const resolveConnection = ( item, data, env = {} ) => {
|
|
1825
3266
|
|
|
1826
3267
|
if( ! item ) return item;
|
|
1827
3268
|
|
|
@@ -1830,10 +3271,10 @@ const resolveConnection = ( item, data ) => {
|
|
|
1830
3271
|
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1831
3272
|
.map( ( [ key, value ] ) => [
|
|
1832
3273
|
key,
|
|
1833
|
-
( typeof value === 'function' ? value( data ) : value )
|
|
3274
|
+
( typeof value === 'function' ? value( data, env ) : value )
|
|
1834
3275
|
] )
|
|
1835
3276
|
);
|
|
1836
3277
|
|
|
1837
3278
|
};
|
|
1838
3279
|
|
|
1839
|
-
export { AUTH_TYPES,
|
|
3280
|
+
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, tokenSettings };
|