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