@drawbridge/drawbridge-utils 0.0.117 → 0.0.118
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/providers.cjs +3336 -0
- package/dist/providers.d.cts +317 -0
- package/dist/providers.d.ts +317 -0
- package/dist/providers.js +3291 -0
- package/package.json +6 -1
|
@@ -0,0 +1,3336 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all2) => {
|
|
8
|
+
for (var name in all2)
|
|
9
|
+
__defProp(target, name, { get: all2[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
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
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// lib/providers.js
|
|
30
|
+
var providers_exports = {};
|
|
31
|
+
__export(providers_exports, {
|
|
32
|
+
cacheKey: () => cacheKey,
|
|
33
|
+
hydrateEnvironment: () => hydrateEnvironment,
|
|
34
|
+
isLive: () => isLive,
|
|
35
|
+
mask: () => mask,
|
|
36
|
+
providerCredentials: () => providerCredentials,
|
|
37
|
+
providerEnvNames: () => providerEnvNames,
|
|
38
|
+
providerFields: () => providerFields,
|
|
39
|
+
providerSettings: () => providerSettings,
|
|
40
|
+
providerSlugs: () => providerSlugs,
|
|
41
|
+
saveProviderSettings: () => saveProviderSettings
|
|
42
|
+
});
|
|
43
|
+
module.exports = __toCommonJS(providers_exports);
|
|
44
|
+
|
|
45
|
+
// lib/connections/contract.js
|
|
46
|
+
var HOOKS = Object.freeze({
|
|
47
|
+
// Proving and holding the credential.
|
|
48
|
+
auth: Object.freeze([
|
|
49
|
+
// Accept what the merchant supplied — a form submission or an OAuth
|
|
50
|
+
// callback — and store what is needed to call the vendor later.
|
|
51
|
+
"connect",
|
|
52
|
+
// Is the stored credential still good? Answered by the cheapest real call
|
|
53
|
+
// the vendor offers, never by inspecting what we stored: a key that was
|
|
54
|
+
// revoked at the vendor still looks perfect in our database.
|
|
55
|
+
"probe",
|
|
56
|
+
// Which permissions we asked for and no longer hold. Distinct from probe:
|
|
57
|
+
// the credential can be valid and the grant still be too narrow.
|
|
58
|
+
"scopes",
|
|
59
|
+
// Revoke at the vendor and drop what we hold.
|
|
60
|
+
"disconnect",
|
|
61
|
+
// MINT A TOKEN — from a consent code, or from a stored refresh token. Both
|
|
62
|
+
// are the same POST, so they are one hook.
|
|
63
|
+
//
|
|
64
|
+
// The default body is authToken() in oauth.js and most vendors point
|
|
65
|
+
// straight at it. It is a hook rather than a declared flag because the
|
|
66
|
+
// vendors that differ, differ in ways config cannot express: Klaviyo needs
|
|
67
|
+
// HTTP Basic where others want body fields, and Mailchimp cannot use the
|
|
68
|
+
// token it receives until a second call tells it which data centre the
|
|
69
|
+
// account is behind.
|
|
70
|
+
"token"
|
|
71
|
+
]),
|
|
72
|
+
// What happens around connecting and disconnecting, beyond the credential.
|
|
73
|
+
lifecycle: Object.freeze([
|
|
74
|
+
// KEEP ACCESS WORKING. Rotate a credential before its window closes, prove
|
|
75
|
+
// it still works, and reconcile whatever the vendor has changed underneath
|
|
76
|
+
// — scopes, webhooks. Distinct from auth.probe, which only answers "is this
|
|
77
|
+
// still good": this one FIXES what it can and reports what it cannot.
|
|
78
|
+
"health",
|
|
79
|
+
// Post-connect setup: register the vendor's webhooks, create the system
|
|
80
|
+
// workflows that describe them.
|
|
81
|
+
"register",
|
|
82
|
+
// Re-pull vendor state we mirror, after a reconnect or on a schedule.
|
|
83
|
+
"rehydrate",
|
|
84
|
+
// Undo `register` — deregister webhooks, release anything reserved.
|
|
85
|
+
"cleanup"
|
|
86
|
+
]),
|
|
87
|
+
// Receiving from the vendor.
|
|
88
|
+
//
|
|
89
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
90
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
91
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
92
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
93
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
94
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
95
|
+
// ever knowing.
|
|
96
|
+
//
|
|
97
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
98
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
99
|
+
// into duplicate deliveries.
|
|
100
|
+
inbound: Object.freeze([
|
|
101
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
102
|
+
// carries. One hook rather than two because for some vendors they are
|
|
103
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
104
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
105
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
106
|
+
//
|
|
107
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
108
|
+
// this returned, which means nothing downstream can act on unverified
|
|
109
|
+
// data even by mistake.
|
|
110
|
+
//
|
|
111
|
+
// It is also where a request gets refused for any other reason — an event
|
|
112
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
113
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
114
|
+
"verify",
|
|
115
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
116
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
117
|
+
// JWT-bodied vendor.
|
|
118
|
+
"event",
|
|
119
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
120
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
121
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
122
|
+
// carriers require) returns that too.
|
|
123
|
+
"receive",
|
|
124
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
125
|
+
//
|
|
126
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
127
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
128
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
129
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
130
|
+
"process"
|
|
131
|
+
]),
|
|
132
|
+
// WHAT A WORKFLOW STEP DOES, as a verb like any other. Step handlers used to
|
|
133
|
+
// live in drawbridge-sync keyed by step type, which meant a vendor's logic sat
|
|
134
|
+
// in a repo the vendor file could not see — the split this whole exercise
|
|
135
|
+
// exists to close.
|
|
136
|
+
//
|
|
137
|
+
// A `steps` entry points at one of these; the step says when it runs and what
|
|
138
|
+
// it costs, the hook does the work.
|
|
139
|
+
contacts: Object.freeze([
|
|
140
|
+
// Forget a contact at the vendor. Distinct from suppression, which keeps the
|
|
141
|
+
// record and marks it unsubscribed: this is deletion, for account closure.
|
|
142
|
+
"remove",
|
|
143
|
+
// Push one contact into the audience the merchant chose, honouring
|
|
144
|
+
// suppression rather than omitting an opted-out person — omission lets them
|
|
145
|
+
// quietly reappear on the next sync.
|
|
146
|
+
"sync"
|
|
147
|
+
]),
|
|
148
|
+
commerce: Object.freeze([
|
|
149
|
+
// Mint a discount code against this merchant's store, mapped to one lead.
|
|
150
|
+
"code",
|
|
151
|
+
// Create the buyer at the vendor, so an order can be attributed to them.
|
|
152
|
+
"customer",
|
|
153
|
+
// An order arrived at the vendor: attribute it, record it, update totals.
|
|
154
|
+
"order",
|
|
155
|
+
// Pull product data across on a vendor update.
|
|
156
|
+
"product"
|
|
157
|
+
]),
|
|
158
|
+
// WHAT DRAWBRIDGE ITSELF DOES. These are not a third party's verbs — nobody
|
|
159
|
+
// connects an account to send email through Drawbridge — but they are steps a
|
|
160
|
+
// workflow runs, and a step points at a hook. So they live on a PRIVATE
|
|
161
|
+
// connection: one that contributes steps and never appears in the catalog.
|
|
162
|
+
//
|
|
163
|
+
// Without it the base steps stay the exception the shell has to know about,
|
|
164
|
+
// and "every step is a declaration pointing at a hook" stops being true the
|
|
165
|
+
// moment somebody looks at the six most common ones.
|
|
166
|
+
email: Object.freeze([
|
|
167
|
+
// To a lead. Suppression applies, and the send is billed.
|
|
168
|
+
"send",
|
|
169
|
+
// To organization members. Never suppressed — an entrant's opt-out must not
|
|
170
|
+
// silence an alert to staff — and never billed.
|
|
171
|
+
"notify",
|
|
172
|
+
// A batched summary to members.
|
|
173
|
+
"digest"
|
|
174
|
+
]),
|
|
175
|
+
sms: Object.freeze(["send"]),
|
|
176
|
+
segment: Object.freeze(["sync"]),
|
|
177
|
+
// OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
|
|
178
|
+
// The Webhooks connection is the only thing here with no third party behind
|
|
179
|
+
// it, and the destination is per STEP rather than per connection.
|
|
180
|
+
webhook: Object.freeze(["send"]),
|
|
181
|
+
// Vendor data we READ — the things a merchant picks from. Named `resources`
|
|
182
|
+
// rather than `catalog` because it also holds audiences, and a catalog is a
|
|
183
|
+
// commerce word for something that is not only commerce. It matches the
|
|
184
|
+
// pickers that render it, too: InputResource and ListResource.
|
|
185
|
+
//
|
|
186
|
+
// The verbs are named for what every vendor has, not for what one calls it:
|
|
187
|
+
// Shopify says discounts, Stripe says coupons and promotion codes,
|
|
188
|
+
// BigCommerce says coupons and promotions.
|
|
189
|
+
//
|
|
190
|
+
// ONE SHAPE FOR ALL OF THEM — searchable and cursor-paged:
|
|
191
|
+
//
|
|
192
|
+
// ({ connection, cursor, limit, search, settings, token })
|
|
193
|
+
// -> { items : [ { id, title, ... } ], pageInfo : { endCursor, hasNextPage } }
|
|
194
|
+
//
|
|
195
|
+
// Lifted from what the Shopify product picker already does, rather than
|
|
196
|
+
// invented: that endpoint takes cursor/limit/search and returns items plus a
|
|
197
|
+
// pageInfo, and ListResource consumes exactly that. It was a good contract
|
|
198
|
+
// written once for one vendor; this makes it the contract.
|
|
199
|
+
//
|
|
200
|
+
// `title` is not arbitrary — ListResource searches `[ 'title' ]` by default,
|
|
201
|
+
// so normalising to { id, title } is what lets a picker work with no
|
|
202
|
+
// per-vendor configuration.
|
|
203
|
+
//
|
|
204
|
+
// HOW a vendor searches is its own business, which is the point of a hook.
|
|
205
|
+
// Shopify pushes the term into its GraphQL query, Klaviyo has a filter
|
|
206
|
+
// parameter, and Mailchimp's /lists has no name filter at all so its hook
|
|
207
|
+
// matches against what it fetched. The caller never learns which.
|
|
208
|
+
resources: Object.freeze([
|
|
209
|
+
// The named groups a contact can be synced INTO. Klaviyo calls them lists,
|
|
210
|
+
// Mailchimp calls them audiences; `audiences` is the industry-generic term
|
|
211
|
+
// and belongs to neither vendor's API. Read at form time, so a merchant
|
|
212
|
+
// picks from what actually exists in their account rather than pasting an
|
|
213
|
+
// id from another tab.
|
|
214
|
+
"audiences",
|
|
215
|
+
"products",
|
|
216
|
+
// Shopify folds price into the product variant; Stripe makes Price a
|
|
217
|
+
// first-class object beside Product. Declared so a vendor that separates
|
|
218
|
+
// them has somewhere to answer.
|
|
219
|
+
"prices",
|
|
220
|
+
"promotions"
|
|
221
|
+
])
|
|
222
|
+
});
|
|
223
|
+
var STEPS = Object.freeze({
|
|
224
|
+
"commerce.code.issue": "Issue discount code",
|
|
225
|
+
"commerce.customer.insert": "Create customer",
|
|
226
|
+
"commerce.order.record": "Record order",
|
|
227
|
+
"commerce.product.sync": "Sync product",
|
|
228
|
+
// Not commerce at all — connection lifecycle, and they generalise to any
|
|
229
|
+
// vendor holding a rotating credential.
|
|
230
|
+
"connection.health.check": "Connection health check",
|
|
231
|
+
"connection.token.exchange": "Exchange token",
|
|
232
|
+
"connection.token.refresh": "Refresh token",
|
|
233
|
+
"contacts.sync": "Sync contact",
|
|
234
|
+
"email.digest": "Digest",
|
|
235
|
+
"email.notify": "Notification",
|
|
236
|
+
"email.send": "Send email",
|
|
237
|
+
"segment.sync": "Sync segment",
|
|
238
|
+
"sms.send": "Send SMS",
|
|
239
|
+
"webhook.send": "Send webhook"
|
|
240
|
+
});
|
|
241
|
+
var RETIRED = Object.freeze({
|
|
242
|
+
"step.shopify.customer.insert": "step.commerce.customer.insert",
|
|
243
|
+
"step.shopify.discount.update": "step.commerce.code.issue",
|
|
244
|
+
"step.shopify.health.check": "step.connection.health.check",
|
|
245
|
+
"step.shopify.order.record": "step.commerce.order.record",
|
|
246
|
+
"step.shopify.product.sync": "step.commerce.product.sync",
|
|
247
|
+
"step.shopify.token.exchange": "step.connection.token.exchange",
|
|
248
|
+
"step.shopify.token.refresh": "step.connection.token.refresh"
|
|
249
|
+
});
|
|
250
|
+
var STEP_TYPES = Object.freeze(Object.keys(STEPS).map((name) => "step." + name));
|
|
251
|
+
var STEP_LABELS = Object.freeze(Object.fromEntries(
|
|
252
|
+
Object.entries(STEPS).map(([name, label]) => ["step." + name, label])
|
|
253
|
+
));
|
|
254
|
+
var HOOK_NAMES = Object.freeze(
|
|
255
|
+
Object.entries(HOOKS).flatMap(([domain, verbs]) => verbs.map((verb) => domain + "." + verb))
|
|
256
|
+
);
|
|
257
|
+
var OUTCOMES = Object.freeze({
|
|
258
|
+
answered: "answered",
|
|
259
|
+
disconnected: "disconnected",
|
|
260
|
+
failed: "failed",
|
|
261
|
+
// Declared supported, implemented in a consumer rather than in this package —
|
|
262
|
+
// sync owns the step handlers and lifecycle jobs. Different from unsupported,
|
|
263
|
+
// which means the vendor cannot do it at all.
|
|
264
|
+
unimplemented: "unimplemented",
|
|
265
|
+
unsupported: "unsupported"
|
|
266
|
+
});
|
|
267
|
+
var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
|
|
268
|
+
var AUTH_TYPES = Object.freeze(["generated", "install", "keys", "none", "oauth"]);
|
|
269
|
+
var GROUPS = Object.freeze(["commerce", "contacts", "developer", "messaging"]);
|
|
270
|
+
var OAUTH_FIELDS = Object.freeze(["client"]);
|
|
271
|
+
var OAUTH_URLS = Object.freeze(["authorize", "redirect", "token"]);
|
|
272
|
+
var INPUTS = Object.freeze([
|
|
273
|
+
"checkbox",
|
|
274
|
+
"email",
|
|
275
|
+
"number",
|
|
276
|
+
"password",
|
|
277
|
+
"select",
|
|
278
|
+
"text",
|
|
279
|
+
"textarea",
|
|
280
|
+
"url"
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
// lib/connections/oauth.js
|
|
284
|
+
var import_node_crypto = require("crypto");
|
|
285
|
+
var credentials = ({ basic, clientId, clientSecret }) => basic ? {
|
|
286
|
+
body: {},
|
|
287
|
+
headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") }
|
|
288
|
+
} : {
|
|
289
|
+
body: { client_id: clientId, client_secret: clientSecret },
|
|
290
|
+
headers: {}
|
|
291
|
+
};
|
|
292
|
+
var authToken = async ({
|
|
293
|
+
basic,
|
|
294
|
+
clientId,
|
|
295
|
+
clientSecret,
|
|
296
|
+
code: code2,
|
|
297
|
+
descriptor,
|
|
298
|
+
fetcher = fetch,
|
|
299
|
+
redirect,
|
|
300
|
+
refreshToken,
|
|
301
|
+
verifier
|
|
302
|
+
} = {}) => {
|
|
303
|
+
var _a;
|
|
304
|
+
if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
|
|
305
|
+
if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.token)) throw new Error("This connection declares no token url");
|
|
306
|
+
const renewing = !code2;
|
|
307
|
+
if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
|
|
308
|
+
if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
|
|
309
|
+
const client = credentials({ basic, clientId, clientSecret });
|
|
310
|
+
const response = await fetcher(descriptor.urls.token, {
|
|
311
|
+
body: new URLSearchParams({
|
|
312
|
+
...client.body,
|
|
313
|
+
...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
|
|
314
|
+
code: decodeURIComponent(String(code2 || "").trim()),
|
|
315
|
+
grant_type: "authorization_code",
|
|
316
|
+
redirect_uri: redirect,
|
|
317
|
+
...descriptor.pkce && { code_verifier: verifier }
|
|
318
|
+
}
|
|
319
|
+
}),
|
|
320
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
321
|
+
method: "POST",
|
|
322
|
+
signal: AbortSignal.timeout(15e3)
|
|
323
|
+
});
|
|
324
|
+
const body = await response.json().catch(() => ({}));
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
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 : "")
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
|
|
331
|
+
return {
|
|
332
|
+
accessToken: body.access_token,
|
|
333
|
+
expiresIn: body.expires_in || null,
|
|
334
|
+
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
335
|
+
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
336
|
+
// at all, so its absence cannot be an error here.
|
|
337
|
+
//
|
|
338
|
+
// On a RENEWAL the same null matters for the opposite reason: a vendor that
|
|
339
|
+
// rotates returns a new one, and dropping it silently invalidates the stored
|
|
340
|
+
// grant on the NEXT refresh — a failure a day late and nowhere near its
|
|
341
|
+
// cause. tokenSettings() keeps the existing one when this is null.
|
|
342
|
+
refreshToken: body.refresh_token || null,
|
|
343
|
+
scope: body.scope || null
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// lib/connections/token.js
|
|
348
|
+
var SKEW_SECONDS = 120;
|
|
349
|
+
var isStale = (settings, now = Date.now()) => {
|
|
350
|
+
if (!(settings == null ? void 0 : settings.expiresAt)) return false;
|
|
351
|
+
return new Date(settings.expiresAt).getTime() - SKEW_SECONDS * 1e3 <= now;
|
|
352
|
+
};
|
|
353
|
+
var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
|
|
354
|
+
accessToken: tokens.accessToken,
|
|
355
|
+
// A vendor that does not rotate its refresh token returns none on a refresh
|
|
356
|
+
// (Klaviyo). Keeping the existing one is what makes the NEXT refresh work —
|
|
357
|
+
// dropping it invalidates the grant one call later, nowhere near the cause.
|
|
358
|
+
...(tokens.refreshToken || existing.refreshToken) && {
|
|
359
|
+
refreshToken: tokens.refreshToken || existing.refreshToken
|
|
360
|
+
},
|
|
361
|
+
// Absent when the vendor issues non-expiring tokens, and absent is meaningful
|
|
362
|
+
// — isStale reads it as "nothing to refresh toward".
|
|
363
|
+
...tokens.expiresIn && {
|
|
364
|
+
expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
|
|
365
|
+
},
|
|
366
|
+
...tokens.scope && { scope: tokens.scope }
|
|
367
|
+
});
|
|
368
|
+
var accessToken = async ({
|
|
369
|
+
clientId,
|
|
370
|
+
clientSecret,
|
|
371
|
+
fetcher,
|
|
372
|
+
force = false,
|
|
373
|
+
manifest,
|
|
374
|
+
now = Date.now(),
|
|
375
|
+
save,
|
|
376
|
+
settings
|
|
377
|
+
} = {}) => {
|
|
378
|
+
if (!(settings == null ? void 0 : settings.accessToken) && !(settings == null ? void 0 : settings.refreshToken)) {
|
|
379
|
+
throw new Error("This connection holds no credential, so there is no token to use");
|
|
380
|
+
}
|
|
381
|
+
if (!force && !isStale(settings, now)) return settings.accessToken;
|
|
382
|
+
if (!settings.refreshToken) {
|
|
383
|
+
throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
|
|
384
|
+
}
|
|
385
|
+
const minted = await manifest.hooks.auth.token({
|
|
386
|
+
clientId,
|
|
387
|
+
clientSecret,
|
|
388
|
+
descriptor: manifest.auth.oauth,
|
|
389
|
+
...fetcher && { fetcher },
|
|
390
|
+
refreshToken: settings.refreshToken
|
|
391
|
+
});
|
|
392
|
+
const next = tokenSettings({ existing: settings, now, tokens: minted });
|
|
393
|
+
if (save) await save(next);
|
|
394
|
+
return next.accessToken;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// lib/connections/icons/attentive.js
|
|
398
|
+
var attentive_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
399
|
+
<rect width="500" height="500" fill="#FFD967"/>
|
|
400
|
+
<path d="M239.369 127.404C260.863 122.013 284.964 132.569 295.576 152.039C321.796 201.286 347.928 250.567 374.131 299.825C380.149 311.989 381.244 326.564 376.733 339.41C371.446 355.097 358.039 367.69 342.107 372.089C326.274 376.67 308.351 372.871 295.741 362.255C288.865 356.892 284.161 349.359 280.354 341.629C261.869 306.95 243.478 272.217 225.071 237.501C214.409 218.614 187.221 212.656 169.622 225.315C168.104 227.309 164.159 226.857 164.72 223.796C176.911 202.569 189.361 181.497 201.662 160.331C205.183 154.428 208.214 148.129 212.967 143.091C219.844 135.272 229.263 129.782 239.374 127.404" fill="#1E1C1C"/>
|
|
401
|
+
<path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
|
|
402
|
+
</svg>`;
|
|
403
|
+
|
|
404
|
+
// lib/connections/attentive.js
|
|
405
|
+
var attentive_default2 = {
|
|
406
|
+
auth: {
|
|
407
|
+
oauth: {
|
|
408
|
+
// NAMES of the env vars holding OUR app's client — set at registration,
|
|
409
|
+
// never before. No `headers` on the client: Attentive takes credentials
|
|
410
|
+
// as form fields, which is the runner's default.
|
|
411
|
+
client: {
|
|
412
|
+
id: "ATTENTIVE_OAUTH_CLIENT_ID",
|
|
413
|
+
secret: "ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
414
|
+
},
|
|
415
|
+
urls: {
|
|
416
|
+
authorize: "https://ui.attentivemobile.com/integrations/oauth-install",
|
|
417
|
+
redirect: "/api/connection/attentive/callback",
|
|
418
|
+
token: "https://api.attentivemobile.com/v1/authorization-codes/tokens"
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
type: "oauth"
|
|
422
|
+
},
|
|
423
|
+
// EVERYTHING A MERCHANT READS. `errors` would live in here too — the
|
|
424
|
+
// connection DOCUMENT carries its own `errors` array and is spread OVER the
|
|
425
|
+
// resolved manifest downstream, so a top-level one would never render.
|
|
426
|
+
content: {
|
|
427
|
+
// SAYS WHAT ACTUALLY HAPPENS. This used to promise that disconnecting
|
|
428
|
+
// revokes Drawbridge's access, and it cannot: Attentive documents no
|
|
429
|
+
// revocation endpoint, and their authentication page states an access
|
|
430
|
+
// token "does not expire". So the grant survives a disconnect forever
|
|
431
|
+
// unless the merchant removes the integration at Attentive, and the copy
|
|
432
|
+
// has to say so rather than let them believe otherwise.
|
|
433
|
+
confirm: "Disconnecting removes Drawbridge's stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge \u2014 neither list is deleted.",
|
|
434
|
+
description: [
|
|
435
|
+
"Attentive is where your SMS marketing lives, and this connection is becoming the way your Drawbridge contacts sync into an Attentive segment.",
|
|
436
|
+
"You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
|
|
437
|
+
"Subscriber syncing is not live yet, so connecting today does nothing except choose the segment it will use when it ships."
|
|
438
|
+
],
|
|
439
|
+
excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
|
|
440
|
+
guide: [
|
|
441
|
+
"Press Connect. Drawbridge sends you to Attentive to approve access.",
|
|
442
|
+
"Sign in to Attentive if you are not already, and authorize the permissions listed.",
|
|
443
|
+
"You are returned here \u2014 choose which Attentive segment your contacts should sync into."
|
|
444
|
+
]
|
|
445
|
+
},
|
|
446
|
+
// A contact destination, like Klaviyo and Mailchimp — a merchant could
|
|
447
|
+
// reasonably keep several up to date at once.
|
|
448
|
+
exclusive: false,
|
|
449
|
+
feature: "organization:connection:attentive",
|
|
450
|
+
fields: [
|
|
451
|
+
{
|
|
452
|
+
input: "select",
|
|
453
|
+
key: "segment",
|
|
454
|
+
label: "Attentive segment",
|
|
455
|
+
message: "Contacts your campaigns collect are synced into this segment.",
|
|
456
|
+
hook: "resources.audiences",
|
|
457
|
+
required: true
|
|
458
|
+
// No `search : false` here, and that is a first: /v2/segments takes a
|
|
459
|
+
// `name` filter (partial match, cited above), so this picker searches
|
|
460
|
+
// the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
|
|
461
|
+
}
|
|
462
|
+
],
|
|
463
|
+
group: "contacts",
|
|
464
|
+
// A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
|
|
465
|
+
// nothing else is built yet, because subscriber sync has not shipped. Every
|
|
466
|
+
// false here is "not yet" rather than "never" — when the sync lands, probe
|
|
467
|
+
// and contacts.sync are the first to flip.
|
|
468
|
+
hooks: {
|
|
469
|
+
auth: {
|
|
470
|
+
// The exchange already yields the tokens, and Attentive documents no
|
|
471
|
+
// account-identity endpoint to enrich them with — Klaviyo's connect
|
|
472
|
+
// reads the account name back; this has nothing cited to read. The
|
|
473
|
+
// callback stores the tokens and skips enrichment on `unimplemented`.
|
|
474
|
+
// FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
|
|
475
|
+
// dependencies", and nothing anywhere implements either of these —
|
|
476
|
+
// there is nothing for them to do. The exchange already yields the
|
|
477
|
+
// tokens and Attentive documents no account-identity endpoint to
|
|
478
|
+
// enrich them with, so connect has nothing to add; and they document
|
|
479
|
+
// no revocation endpoint at all, so disconnect has nothing to call.
|
|
480
|
+
// Recorded as a decision rather than left as an unkept promise.
|
|
481
|
+
connect: false,
|
|
482
|
+
disconnect: false,
|
|
483
|
+
probe: false,
|
|
484
|
+
scopes: false,
|
|
485
|
+
// THE ONE THING WRAPPED, and it is about the response rather than the
|
|
486
|
+
// request. Attentive's token reply carries expires_in : 900 while their
|
|
487
|
+
// auth overview says access tokens "do not expire" — and no refresh
|
|
488
|
+
// token exists to renew with. Storing that expiry would have
|
|
489
|
+
// accessToken() declaring the credential spent fifteen minutes after
|
|
490
|
+
// consent, with nothing to renew it: every connection would demand
|
|
491
|
+
// reconnecting four times an hour.
|
|
492
|
+
//
|
|
493
|
+
// The overview's answer is modelled — the expiry is dropped, so the
|
|
494
|
+
// token is treated as long-lived. Registration item 1 in the header is
|
|
495
|
+
// the live exchange that proves this right or wrong.
|
|
496
|
+
token: async (args) => {
|
|
497
|
+
const minted = await authToken(args);
|
|
498
|
+
return { ...minted, expiresIn: null };
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
commerce: false,
|
|
502
|
+
contacts: { remove: false, sync: false },
|
|
503
|
+
email: false,
|
|
504
|
+
inbound: false,
|
|
505
|
+
lifecycle: false,
|
|
506
|
+
resources: {
|
|
507
|
+
// The segments a merchant can sync into, for the picker on their
|
|
508
|
+
// connection.
|
|
509
|
+
//
|
|
510
|
+
// GET /v2/segments (cited in the header). `limit` caps at 1000 in their
|
|
511
|
+
// own spec, defaulting to 20 — low enough that leaving it unset would
|
|
512
|
+
// show a picker quietly missing most of a real account. The response's
|
|
513
|
+
// only identifier is `externalId`, so an entry without one cannot be
|
|
514
|
+
// stored and is dropped.
|
|
515
|
+
audiences: async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
|
|
516
|
+
const query = new URLSearchParams({
|
|
517
|
+
limit: String(Math.min(limit, 1e3)),
|
|
518
|
+
...cursor && { cursor },
|
|
519
|
+
...(search == null ? void 0 : search.value) && { name: String(search.value).trim() }
|
|
520
|
+
});
|
|
521
|
+
const response = await fetcher(
|
|
522
|
+
"https://api.attentivemobile.com/v2/segments?" + query,
|
|
523
|
+
{
|
|
524
|
+
headers: { authorization: "Bearer " + token },
|
|
525
|
+
signal: AbortSignal.timeout(15e3)
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
throw Object.assign(
|
|
530
|
+
new Error("Attentive refused the request (" + response.status + ")"),
|
|
531
|
+
{ status: response.status }
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const body = await response.json();
|
|
535
|
+
return {
|
|
536
|
+
items: ((body == null ? void 0 : body.segments) || []).filter((segment) => segment == null ? void 0 : segment.externalId).map((segment) => ({ id: segment.externalId, title: (segment == null ? void 0 : segment.name) || segment.externalId })),
|
|
537
|
+
pageInfo: {
|
|
538
|
+
endCursor: (body == null ? void 0 : body.hasMore) ? (body == null ? void 0 : body.cursor) || null : null,
|
|
539
|
+
hasNextPage: Boolean(body == null ? void 0 : body.hasMore)
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
prices: false,
|
|
544
|
+
products: false,
|
|
545
|
+
promotions: false
|
|
546
|
+
},
|
|
547
|
+
segment: false,
|
|
548
|
+
sms: false,
|
|
549
|
+
webhook: false
|
|
550
|
+
},
|
|
551
|
+
icon: attentive_default,
|
|
552
|
+
requires: [
|
|
553
|
+
"ATTENTIVE_OAUTH_CLIENT_ID",
|
|
554
|
+
"ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
555
|
+
],
|
|
556
|
+
slug: "attentive",
|
|
557
|
+
// A consent with no segment chosen is authenticated and inert — the sync,
|
|
558
|
+
// when it ships, needs somewhere to put people.
|
|
559
|
+
status: (data2) => {
|
|
560
|
+
var _a;
|
|
561
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? data2.status : "pending";
|
|
562
|
+
},
|
|
563
|
+
// No steps: subscriber sync has not shipped, so this vendor contributes
|
|
564
|
+
// nothing to a workflow yet. An empty steps object is the honest declaration.
|
|
565
|
+
steps: {},
|
|
566
|
+
tasks: (data2) => {
|
|
567
|
+
var _a;
|
|
568
|
+
return [
|
|
569
|
+
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
|
|
570
|
+
{
|
|
571
|
+
message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
|
|
572
|
+
title: "Choose a segment"
|
|
573
|
+
}
|
|
574
|
+
],
|
|
575
|
+
{
|
|
576
|
+
message: "Contact syncing to Attentive segments has not shipped yet. Nothing is being sent to Attentive right now.",
|
|
577
|
+
title: "Subscriber sync not available yet",
|
|
578
|
+
type: "warning"
|
|
579
|
+
}
|
|
580
|
+
];
|
|
581
|
+
},
|
|
582
|
+
title: "Attentive"
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// lib/http.js
|
|
586
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
587
|
+
var request = async ({
|
|
588
|
+
body,
|
|
589
|
+
headers = {},
|
|
590
|
+
method = "GET",
|
|
591
|
+
query,
|
|
592
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
593
|
+
type = "json",
|
|
594
|
+
url
|
|
595
|
+
}) => {
|
|
596
|
+
const fullUrl = new URL(url);
|
|
597
|
+
if (query) {
|
|
598
|
+
Object.entries(query).forEach(([k, v]) => fullUrl.searchParams.set(k, v));
|
|
599
|
+
}
|
|
600
|
+
;
|
|
601
|
+
const isForm = type === "form";
|
|
602
|
+
const response = await fetch(fullUrl.toString(), {
|
|
603
|
+
method,
|
|
604
|
+
headers: {
|
|
605
|
+
"Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
|
|
606
|
+
...headers
|
|
607
|
+
},
|
|
608
|
+
signal: AbortSignal.timeout(timeout),
|
|
609
|
+
...body !== void 0 && {
|
|
610
|
+
body: isForm ? new URLSearchParams(body).toString() : JSON.stringify(body)
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
if (!response.ok) {
|
|
614
|
+
const text2 = await response.text().catch(() => "");
|
|
615
|
+
const error = new Error(text2 || response.statusText);
|
|
616
|
+
error.status = response.status;
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
;
|
|
620
|
+
const text = await response.text();
|
|
621
|
+
try {
|
|
622
|
+
return text ? JSON.parse(text) : null;
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// lib/hubspot.js
|
|
629
|
+
var HUBSPOT_BASE = "https://api.hubapi.com";
|
|
630
|
+
var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
631
|
+
return (fetcher || request)({
|
|
632
|
+
body,
|
|
633
|
+
headers: {
|
|
634
|
+
"Authorization": "Bearer " + (token || process.env.HUBSPOT_ACCESS_TOKEN)
|
|
635
|
+
},
|
|
636
|
+
method,
|
|
637
|
+
query,
|
|
638
|
+
url: HUBSPOT_BASE + path
|
|
639
|
+
});
|
|
640
|
+
};
|
|
641
|
+
var UTM_PROPERTIES = {
|
|
642
|
+
campaign: "utm_campaign",
|
|
643
|
+
content: "utm_content",
|
|
644
|
+
id: "utm_id",
|
|
645
|
+
medium: "utm_medium",
|
|
646
|
+
source: "utm_source",
|
|
647
|
+
term: "utm_term"
|
|
648
|
+
};
|
|
649
|
+
var CLICK_PROPERTIES = {
|
|
650
|
+
fbclid: "hs_facebook_click_id",
|
|
651
|
+
gclid: "hs_google_click_id",
|
|
652
|
+
liFatId: "hs_linkedin_click_id",
|
|
653
|
+
msclkid: "hs_bing_click_id",
|
|
654
|
+
ttclid: "hs_tiktok_click_id"
|
|
655
|
+
};
|
|
656
|
+
var DROPPABLE = new Set(Object.values(UTM_PROPERTIES));
|
|
657
|
+
var isUtmProperty = (key) => DROPPABLE.has(key);
|
|
658
|
+
var toProperties = ({ email, firstName, lastName, utm }) => {
|
|
659
|
+
var _a;
|
|
660
|
+
const properties = {};
|
|
661
|
+
if (email !== void 0) properties.email = email;
|
|
662
|
+
if (firstName !== void 0) properties.firstname = firstName;
|
|
663
|
+
if (lastName !== void 0) properties.lastname = lastName;
|
|
664
|
+
if (utm) {
|
|
665
|
+
for (const [key, property] of Object.entries(UTM_PROPERTIES)) {
|
|
666
|
+
if (utm[key]) properties[property] = utm[key];
|
|
667
|
+
}
|
|
668
|
+
;
|
|
669
|
+
for (const [key, property] of Object.entries(CLICK_PROPERTIES)) {
|
|
670
|
+
if ((_a = utm.click) == null ? void 0 : _a[key]) properties[property] = utm.click[key];
|
|
671
|
+
}
|
|
672
|
+
;
|
|
673
|
+
}
|
|
674
|
+
;
|
|
675
|
+
return properties;
|
|
676
|
+
};
|
|
677
|
+
var send = async ({ doc, fetcher, method, path, token }) => {
|
|
678
|
+
const properties = toProperties(doc);
|
|
679
|
+
try {
|
|
680
|
+
return await hubspotRequest({
|
|
681
|
+
body: { properties },
|
|
682
|
+
fetcher,
|
|
683
|
+
method,
|
|
684
|
+
path,
|
|
685
|
+
token
|
|
686
|
+
});
|
|
687
|
+
} catch (error) {
|
|
688
|
+
const enriched = Object.keys(properties).some(isUtmProperty);
|
|
689
|
+
if ((error == null ? void 0 : error.status) !== 400 || !enriched) throw error;
|
|
690
|
+
return await hubspotRequest({
|
|
691
|
+
body: {
|
|
692
|
+
properties: Object.fromEntries(
|
|
693
|
+
Object.entries(properties).filter(([key]) => !isUtmProperty(key))
|
|
694
|
+
)
|
|
695
|
+
},
|
|
696
|
+
fetcher,
|
|
697
|
+
method,
|
|
698
|
+
path,
|
|
699
|
+
token
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
var lookup = async ({ email, fetcher, token }) => {
|
|
704
|
+
var _a, _b;
|
|
705
|
+
if (!token || !email) return;
|
|
706
|
+
try {
|
|
707
|
+
const body = await hubspotRequest({
|
|
708
|
+
body: {
|
|
709
|
+
filterGroups: [
|
|
710
|
+
{
|
|
711
|
+
filters: [
|
|
712
|
+
{
|
|
713
|
+
operator: "EQ",
|
|
714
|
+
propertyName: "email",
|
|
715
|
+
value: email
|
|
716
|
+
}
|
|
717
|
+
]
|
|
718
|
+
}
|
|
719
|
+
],
|
|
720
|
+
limit: 1,
|
|
721
|
+
properties: ["email"]
|
|
722
|
+
},
|
|
723
|
+
fetcher,
|
|
724
|
+
method: "POST",
|
|
725
|
+
path: "/crm/v3/objects/contacts/search",
|
|
726
|
+
token
|
|
727
|
+
});
|
|
728
|
+
return (_b = (_a = body == null ? void 0 : body.results) == null ? void 0 : _a[0]) == null ? void 0 : _b.id;
|
|
729
|
+
} catch (error) {
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
var contacts = {
|
|
733
|
+
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
734
|
+
// to search then remove, which is one round trip it should not have to
|
|
735
|
+
// know about.
|
|
736
|
+
remove: async ({ email, fetcher, id, token }) => {
|
|
737
|
+
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
738
|
+
if (!key) return;
|
|
739
|
+
const contact = id || await lookup({ email, fetcher, token: key });
|
|
740
|
+
if (!contact) return;
|
|
741
|
+
return hubspotRequest({
|
|
742
|
+
fetcher,
|
|
743
|
+
method: "DELETE",
|
|
744
|
+
path: "/crm/v3/objects/contacts/" + contact,
|
|
745
|
+
token: key
|
|
746
|
+
});
|
|
747
|
+
},
|
|
748
|
+
// Connect an account to its contact by email, creating it if absent, and
|
|
749
|
+
// return the contact id. Unlike SendGrid, HubSpot renames a contact's
|
|
750
|
+
// email in place, so an email change is a plain PATCH on the cached id —
|
|
751
|
+
// no delete-old-then-create-new.
|
|
752
|
+
//
|
|
753
|
+
// Prefer the cached hubspotId; fall back to a search; create last.
|
|
754
|
+
sync: async ({ doc, fetcher, token }) => {
|
|
755
|
+
var _a, _b;
|
|
756
|
+
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
757
|
+
if (!key) return;
|
|
758
|
+
if (doc == null ? void 0 : doc.hubspotId) {
|
|
759
|
+
try {
|
|
760
|
+
return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token: key })) == null ? void 0 : _a.id;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if ((error == null ? void 0 : error.status) !== 404) throw error;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token: key });
|
|
766
|
+
return (_b = await send({
|
|
767
|
+
doc,
|
|
768
|
+
fetcher,
|
|
769
|
+
method: existing ? "PATCH" : "POST",
|
|
770
|
+
path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
|
|
771
|
+
token: key
|
|
772
|
+
})) == null ? void 0 : _b.id;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// lib/connections/icons/drawbridge.js
|
|
777
|
+
var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
778
|
+
<rect width="500" height="500" fill="#BAEC5F"/>
|
|
779
|
+
<g clip-path="url(#clip0_2115_2832)">
|
|
780
|
+
<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"/>
|
|
781
|
+
<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"/>
|
|
782
|
+
</g>
|
|
783
|
+
<defs>
|
|
784
|
+
<clipPath id="clip0_2115_2832">
|
|
785
|
+
<rect width="220" height="284" fill="white" transform="translate(140 108)"/>
|
|
786
|
+
</clipPath>
|
|
787
|
+
</defs>
|
|
788
|
+
</svg>`;
|
|
789
|
+
|
|
790
|
+
// lib/features.js
|
|
791
|
+
var page = {
|
|
792
|
+
qrcode: {
|
|
793
|
+
key: "page:qrcode",
|
|
794
|
+
error: "Plan does not include qrcodes",
|
|
795
|
+
feature: "Page qrcode management"
|
|
796
|
+
},
|
|
797
|
+
shortcode: {
|
|
798
|
+
key: "page:shortcode",
|
|
799
|
+
error: "Plan does not include shortcodes",
|
|
800
|
+
feature: "Page shortcode management"
|
|
801
|
+
},
|
|
802
|
+
slug: {
|
|
803
|
+
key: "page:slug",
|
|
804
|
+
error: "Plan does not include url customization",
|
|
805
|
+
feature: "Page slug customization"
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
var fields = {
|
|
809
|
+
additional: {
|
|
810
|
+
key: "campaign:fields:additional",
|
|
811
|
+
error: "Plan does not include additional fields",
|
|
812
|
+
feature: "Campaign additional fields"
|
|
813
|
+
},
|
|
814
|
+
lead: {
|
|
815
|
+
key: "campaign:fields:lead",
|
|
816
|
+
error: "Plan does not include lead fields",
|
|
817
|
+
feature: "Campaign lead fields"
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
var field = {
|
|
821
|
+
email: {
|
|
822
|
+
key: "campaign:field:email",
|
|
823
|
+
error: "Plan does not include email field",
|
|
824
|
+
feature: "Campaign email field"
|
|
825
|
+
},
|
|
826
|
+
name: {
|
|
827
|
+
key: "campaign:field:name",
|
|
828
|
+
error: "Plan does not include name field",
|
|
829
|
+
feature: "Campaign name field"
|
|
830
|
+
},
|
|
831
|
+
number: {
|
|
832
|
+
key: "campaign:field:number",
|
|
833
|
+
error: "Plan does not include number field",
|
|
834
|
+
feature: "Campaign number field"
|
|
835
|
+
},
|
|
836
|
+
phone: {
|
|
837
|
+
key: "campaign:field:phone",
|
|
838
|
+
error: "Plan does not include phone field",
|
|
839
|
+
feature: "Campaign phone field"
|
|
840
|
+
},
|
|
841
|
+
select: {
|
|
842
|
+
key: "campaign:field:select",
|
|
843
|
+
error: "Plan does not include dropdown field",
|
|
844
|
+
feature: "Campaign dropdown field"
|
|
845
|
+
},
|
|
846
|
+
text: {
|
|
847
|
+
key: "campaign:field:text",
|
|
848
|
+
error: "Plan does not include short text field",
|
|
849
|
+
feature: "Campaign short text field"
|
|
850
|
+
},
|
|
851
|
+
textarea: {
|
|
852
|
+
key: "campaign:field:textarea",
|
|
853
|
+
error: "Plan does not include long text field",
|
|
854
|
+
feature: "Campaign long text field"
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
var connection = {
|
|
858
|
+
attentive: {
|
|
859
|
+
key: "organization:connection:attentive",
|
|
860
|
+
error: "Plan does not include Attentive connection",
|
|
861
|
+
feature: "Attentive connection"
|
|
862
|
+
},
|
|
863
|
+
// Klaviyo shipped without an entry here, which meant no plan GRANTED its
|
|
864
|
+
// key and the feature gate denied every non-admin request — a latent 403
|
|
865
|
+
// found while adding Attentive. getPlanFeature answers granted:false for a
|
|
866
|
+
// key absent from the plan's map, so a manifest feature key that appears in
|
|
867
|
+
// no plan list is a connection only admins can manage.
|
|
868
|
+
klaviyo: {
|
|
869
|
+
key: "organization:connection:klaviyo",
|
|
870
|
+
error: "Plan does not include Klaviyo connection",
|
|
871
|
+
feature: "Klaviyo connection"
|
|
872
|
+
},
|
|
873
|
+
mailchimp: {
|
|
874
|
+
key: "organization:connection:mailchimp",
|
|
875
|
+
error: "Plan does not include Mailchimp connection",
|
|
876
|
+
feature: "Mailchimp connection"
|
|
877
|
+
},
|
|
878
|
+
sendgrid: {
|
|
879
|
+
key: "organization:connection:sendgrid",
|
|
880
|
+
error: "Plan does not include SendGrid connection",
|
|
881
|
+
feature: "SendGrid connection"
|
|
882
|
+
},
|
|
883
|
+
shopify: {
|
|
884
|
+
key: "organization:connection:shopify",
|
|
885
|
+
error: "Plan does not include Shopify connection",
|
|
886
|
+
feature: "Shopify connection"
|
|
887
|
+
},
|
|
888
|
+
twilio: {
|
|
889
|
+
key: "organization:connection:twilio",
|
|
890
|
+
error: "Plan does not include Twilio connection",
|
|
891
|
+
feature: "Twilio connection"
|
|
892
|
+
},
|
|
893
|
+
webhook: {
|
|
894
|
+
key: "organization:connection:webhook",
|
|
895
|
+
error: "Plan does not include Webhook connection",
|
|
896
|
+
feature: "Webhook connection"
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
var organization = {
|
|
900
|
+
advertisements: {
|
|
901
|
+
key: "organization:advertisements",
|
|
902
|
+
error: "Your plan does not include advertisements",
|
|
903
|
+
feature: "Organization advertisement management"
|
|
904
|
+
},
|
|
905
|
+
affiliates: {
|
|
906
|
+
key: "organization:affiliates",
|
|
907
|
+
error: "Your plan does not include affiliates",
|
|
908
|
+
feature: "Organization affiliates management"
|
|
909
|
+
},
|
|
910
|
+
analytics: {
|
|
911
|
+
key: "organization:analytics",
|
|
912
|
+
error: "Your plan does not include analytics",
|
|
913
|
+
feature: "Organization analytics management"
|
|
914
|
+
},
|
|
915
|
+
brands: {
|
|
916
|
+
key: "organization:brands",
|
|
917
|
+
error: "Your plan does not include brands",
|
|
918
|
+
feature: "Organization brands management"
|
|
919
|
+
},
|
|
920
|
+
// Gates the Networking section as a whole — a verified sending domain today,
|
|
921
|
+
// the SMS number and a custom page domain as they land. One key rather than
|
|
922
|
+
// one per type: they are the same capability to a merchant, and splitting
|
|
923
|
+
// them would mean a plan could grant half a section.
|
|
924
|
+
//
|
|
925
|
+
// It replaces `connection.sender`, which named a connection this stopped
|
|
926
|
+
// being. Free organizations cannot send lead-facing email at all, so a
|
|
927
|
+
// sending identity there is one they could never send from.
|
|
928
|
+
networking: {
|
|
929
|
+
key: "organization:networking",
|
|
930
|
+
error: "Your plan does not include a custom sending identity",
|
|
931
|
+
feature: "Organization networking"
|
|
932
|
+
},
|
|
933
|
+
members: {
|
|
934
|
+
key: "organization:members",
|
|
935
|
+
error: "Your plan does not include team members",
|
|
936
|
+
feature: "Organization members management"
|
|
937
|
+
},
|
|
938
|
+
reports: {
|
|
939
|
+
key: "organization:report",
|
|
940
|
+
error: "Plan does not include report generation",
|
|
941
|
+
feature: "Organization report generation"
|
|
942
|
+
},
|
|
943
|
+
subdomain: {
|
|
944
|
+
key: "organization:subdomain",
|
|
945
|
+
error: "Plan does not include subdomain customization",
|
|
946
|
+
feature: "Organization subdomain customization"
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
// index.js
|
|
951
|
+
var import_currency_codes = require("currency-codes");
|
|
952
|
+
var import_nanoid = require("nanoid");
|
|
953
|
+
|
|
954
|
+
// lib/color.js
|
|
955
|
+
var import_tinycolor2 = __toESM(require("tinycolor2"), 1);
|
|
956
|
+
var colorFormatted = (value) => {
|
|
957
|
+
const color = (0, import_tinycolor2.default)(value);
|
|
958
|
+
const attributes = {
|
|
959
|
+
brightness: color.getBrightness(),
|
|
960
|
+
dark: color.isDark(),
|
|
961
|
+
light: color.isLight(),
|
|
962
|
+
luminance: color.getLuminance()
|
|
963
|
+
};
|
|
964
|
+
return {
|
|
965
|
+
attributes,
|
|
966
|
+
hex: color.toHexString(),
|
|
967
|
+
hsl: color.toHsl(),
|
|
968
|
+
hsv: color.toHsv(),
|
|
969
|
+
rgb: color.toRgbString()
|
|
970
|
+
};
|
|
971
|
+
};
|
|
972
|
+
var colorAccessible = (background2) => {
|
|
973
|
+
const white = "#ffffff";
|
|
974
|
+
const black = "#000000";
|
|
975
|
+
return import_tinycolor2.default.isReadable(
|
|
976
|
+
background2,
|
|
977
|
+
white,
|
|
978
|
+
{
|
|
979
|
+
level: "AA",
|
|
980
|
+
size: "normal"
|
|
981
|
+
}
|
|
982
|
+
) ? white : black;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
// lib/constants.js
|
|
986
|
+
var font = {
|
|
987
|
+
family: "Roboto Flex",
|
|
988
|
+
transform: "none",
|
|
989
|
+
weight: "regular"
|
|
990
|
+
};
|
|
991
|
+
var background = "#ffffff";
|
|
992
|
+
var style = {
|
|
993
|
+
background: {
|
|
994
|
+
color: colorFormatted(background)
|
|
995
|
+
},
|
|
996
|
+
body: font,
|
|
997
|
+
button: {
|
|
998
|
+
background: {
|
|
999
|
+
color: colorFormatted(background)
|
|
1000
|
+
},
|
|
1001
|
+
radius: 0,
|
|
1002
|
+
text: {
|
|
1003
|
+
color: colorFormatted(colorAccessible(background))
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
heading: font,
|
|
1007
|
+
input: {
|
|
1008
|
+
radius: 0
|
|
1009
|
+
},
|
|
1010
|
+
text: {
|
|
1011
|
+
color: colorFormatted(colorAccessible(background))
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// index.js
|
|
1016
|
+
var nanoid = (0, import_nanoid.customAlphabet)("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
1017
|
+
var infinite = 1e300;
|
|
1018
|
+
var megabyte = 1024 * 1024;
|
|
1019
|
+
var gigabyte = megabyte * 1024;
|
|
1020
|
+
var currencies = import_currency_codes.data.map((item) => ({
|
|
1021
|
+
...item,
|
|
1022
|
+
key: item.currency,
|
|
1023
|
+
value: item.code
|
|
1024
|
+
}));
|
|
1025
|
+
|
|
1026
|
+
// lib/plans.js
|
|
1027
|
+
var featuresFor = (array = []) => Object.values({
|
|
1028
|
+
...connection,
|
|
1029
|
+
...organization,
|
|
1030
|
+
...fields,
|
|
1031
|
+
...field,
|
|
1032
|
+
...page
|
|
1033
|
+
}).reduce(
|
|
1034
|
+
(accumulator, { key, error, feature }) => {
|
|
1035
|
+
if (array.includes(key)) {
|
|
1036
|
+
accumulator.granted[key] = feature;
|
|
1037
|
+
} else {
|
|
1038
|
+
accumulator.denied[key] = error;
|
|
1039
|
+
}
|
|
1040
|
+
return accumulator;
|
|
1041
|
+
},
|
|
1042
|
+
{ denied: {}, granted: {} }
|
|
1043
|
+
);
|
|
1044
|
+
var overage = (actionCents) => ({
|
|
1045
|
+
actionCents,
|
|
1046
|
+
overages: { actions: String(actionCents) }
|
|
1047
|
+
});
|
|
1048
|
+
var all = {
|
|
1049
|
+
features: (array = []) => featuresFor([
|
|
1050
|
+
connection.attentive.key,
|
|
1051
|
+
connection.klaviyo.key,
|
|
1052
|
+
connection.mailchimp.key,
|
|
1053
|
+
connection.sendgrid.key,
|
|
1054
|
+
connection.shopify.key,
|
|
1055
|
+
connection.twilio.key,
|
|
1056
|
+
connection.webhook.key,
|
|
1057
|
+
organization.affiliates.key,
|
|
1058
|
+
organization.brands.key,
|
|
1059
|
+
fields.additional.key,
|
|
1060
|
+
fields.lead.key,
|
|
1061
|
+
field.email.key,
|
|
1062
|
+
field.name.key,
|
|
1063
|
+
field.number.key,
|
|
1064
|
+
field.phone.key,
|
|
1065
|
+
field.select.key,
|
|
1066
|
+
field.text.key,
|
|
1067
|
+
field.textarea.key,
|
|
1068
|
+
page.qrcode.key,
|
|
1069
|
+
page.shortcode.key,
|
|
1070
|
+
...array
|
|
1071
|
+
]),
|
|
1072
|
+
// `members` and `storage` default to infinite so an unnamed term on a custom
|
|
1073
|
+
// plan reads as UNLIMITED rather than absent. Storage used to have no
|
|
1074
|
+
// default, so a deal that did not name it resolved to undefined and the
|
|
1075
|
+
// organization's usage card simply omitted the row — the same blank field
|
|
1076
|
+
// that showed "Unlimited" for members showed nothing at all for storage.
|
|
1077
|
+
// Every catalog plan names both, so the defaults only ever apply to a
|
|
1078
|
+
// custom plan. `actions` has no default on purpose: an unnamed allowance
|
|
1079
|
+
// bills nothing, which is why the availability switch refuses to flip
|
|
1080
|
+
// without one.
|
|
1081
|
+
limits: ({ actions, members = infinite, storage = infinite }) => ({
|
|
1082
|
+
campaign: {
|
|
1083
|
+
advertisements: infinite,
|
|
1084
|
+
links: infinite,
|
|
1085
|
+
fields: infinite,
|
|
1086
|
+
pages: infinite
|
|
1087
|
+
},
|
|
1088
|
+
organization: {
|
|
1089
|
+
actions,
|
|
1090
|
+
affiliates: infinite,
|
|
1091
|
+
brands: infinite,
|
|
1092
|
+
campaigns: infinite,
|
|
1093
|
+
members,
|
|
1094
|
+
storage
|
|
1095
|
+
}
|
|
1096
|
+
})
|
|
1097
|
+
};
|
|
1098
|
+
var free = {
|
|
1099
|
+
conversion: 3,
|
|
1100
|
+
features: all.features(),
|
|
1101
|
+
limits: all.limits({
|
|
1102
|
+
actions: 200,
|
|
1103
|
+
members: 0,
|
|
1104
|
+
storage: gigabyte * 5
|
|
1105
|
+
}),
|
|
1106
|
+
title: "Free"
|
|
1107
|
+
};
|
|
1108
|
+
var plans = {
|
|
1109
|
+
DB00002: {
|
|
1110
|
+
// A verified sending domain is a PAID capability: free plans cannot send
|
|
1111
|
+
// lead-facing email at all (the send path gates on an active
|
|
1112
|
+
// subscription), so granting it there would offer a domain that can
|
|
1113
|
+
// never send from.
|
|
1114
|
+
features: all.features([organization.networking.key, organization.members.key]),
|
|
1115
|
+
limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
|
|
1116
|
+
marketing: {
|
|
1117
|
+
description: "Tools to fine-tune campaigns and improve lead quality.",
|
|
1118
|
+
features: [],
|
|
1119
|
+
limits: [
|
|
1120
|
+
["Actions per month", "5,000"],
|
|
1121
|
+
["Affiliates", "Unlimited"],
|
|
1122
|
+
["Brands", "Unlimited"],
|
|
1123
|
+
["Campaigns", "Unlimited"],
|
|
1124
|
+
["Pages", "Unlimited"],
|
|
1125
|
+
["Members", "3"],
|
|
1126
|
+
["Storage", "10GB"]
|
|
1127
|
+
]
|
|
1128
|
+
},
|
|
1129
|
+
...overage(2.5),
|
|
1130
|
+
title: "Starter",
|
|
1131
|
+
conversion: 2
|
|
1132
|
+
},
|
|
1133
|
+
DB00003: {
|
|
1134
|
+
features: all.features([
|
|
1135
|
+
organization.networking.key,
|
|
1136
|
+
organization.advertisements.key,
|
|
1137
|
+
organization.analytics.key,
|
|
1138
|
+
organization.members.key,
|
|
1139
|
+
organization.subdomain.key,
|
|
1140
|
+
page.slug.key
|
|
1141
|
+
]),
|
|
1142
|
+
limits: all.limits({ actions: 15e3, members: 5, storage: gigabyte * 20 }),
|
|
1143
|
+
marketing: {
|
|
1144
|
+
description: "Expand your reach and grow your lead pipeline.",
|
|
1145
|
+
features: [
|
|
1146
|
+
"Analytics",
|
|
1147
|
+
"Custom subdomain / URLs",
|
|
1148
|
+
"Confirmation page ads"
|
|
1149
|
+
],
|
|
1150
|
+
limits: [
|
|
1151
|
+
["Actions per month", "15,000"],
|
|
1152
|
+
["Affiliates", "Unlimited"],
|
|
1153
|
+
["Brands", "Unlimited"],
|
|
1154
|
+
["Campaigns", "Unlimited"],
|
|
1155
|
+
["Pages", "Unlimited"],
|
|
1156
|
+
["Members", "5"],
|
|
1157
|
+
["Storage", "20GB"]
|
|
1158
|
+
]
|
|
1159
|
+
},
|
|
1160
|
+
...overage(2),
|
|
1161
|
+
title: "Pro",
|
|
1162
|
+
conversion: 1.5
|
|
1163
|
+
},
|
|
1164
|
+
DB00004: {
|
|
1165
|
+
features: all.features([
|
|
1166
|
+
organization.networking.key,
|
|
1167
|
+
organization.advertisements.key,
|
|
1168
|
+
organization.analytics.key,
|
|
1169
|
+
organization.members.key,
|
|
1170
|
+
organization.subdomain.key,
|
|
1171
|
+
page.slug.key
|
|
1172
|
+
]),
|
|
1173
|
+
limits: all.limits({ actions: 4e4, members: 10, storage: gigabyte * 50 }),
|
|
1174
|
+
marketing: {
|
|
1175
|
+
description: "Accelerate acquisition with more power and flexibility.",
|
|
1176
|
+
features: [
|
|
1177
|
+
"Analytics",
|
|
1178
|
+
"Custom subdomain / URLs",
|
|
1179
|
+
"Confirmation page ads"
|
|
1180
|
+
],
|
|
1181
|
+
limits: [
|
|
1182
|
+
["Actions per month", "40,000"],
|
|
1183
|
+
["Affiliates", "Unlimited"],
|
|
1184
|
+
["Brands", "Unlimited"],
|
|
1185
|
+
["Campaigns", "Unlimited"],
|
|
1186
|
+
["Pages", "Unlimited"],
|
|
1187
|
+
["Members", "10"],
|
|
1188
|
+
["Storage", "50GB"]
|
|
1189
|
+
]
|
|
1190
|
+
},
|
|
1191
|
+
...overage(1.85),
|
|
1192
|
+
title: "Premium",
|
|
1193
|
+
conversion: 1
|
|
1194
|
+
},
|
|
1195
|
+
DB00005: {
|
|
1196
|
+
features: all.features([
|
|
1197
|
+
organization.networking.key,
|
|
1198
|
+
organization.advertisements.key,
|
|
1199
|
+
organization.analytics.key,
|
|
1200
|
+
organization.members.key,
|
|
1201
|
+
organization.subdomain.key,
|
|
1202
|
+
page.slug.key
|
|
1203
|
+
]),
|
|
1204
|
+
limits: all.limits({ actions: 1e5, members: infinite, storage: gigabyte * 100 }),
|
|
1205
|
+
marketing: {
|
|
1206
|
+
description: "Built for brands focused on results.",
|
|
1207
|
+
features: [
|
|
1208
|
+
"Analytics",
|
|
1209
|
+
"Custom subdomain / URLs",
|
|
1210
|
+
"Confirmation page ads"
|
|
1211
|
+
],
|
|
1212
|
+
limits: [
|
|
1213
|
+
["Actions per month", "100,000"],
|
|
1214
|
+
["Affiliates", "Unlimited"],
|
|
1215
|
+
["Brands", "Unlimited"],
|
|
1216
|
+
["Campaigns", "Unlimited"],
|
|
1217
|
+
["Pages", "Unlimited"],
|
|
1218
|
+
["Members", "Unlimited"],
|
|
1219
|
+
["Storage", "100GB"]
|
|
1220
|
+
]
|
|
1221
|
+
},
|
|
1222
|
+
...overage(1.5),
|
|
1223
|
+
title: "Elite",
|
|
1224
|
+
conversion: 0.5
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// lib/transactions.js
|
|
1229
|
+
var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
|
|
1230
|
+
|
|
1231
|
+
// lib/billing.js
|
|
1232
|
+
var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
|
|
1233
|
+
var logger = (0, import_drawbridge_telemetry2.createLogger)();
|
|
1234
|
+
var MARKUP = 1.3;
|
|
1235
|
+
var cost = {
|
|
1236
|
+
// gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
|
|
1237
|
+
// $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
|
|
1238
|
+
// output). ~3.6x the retired 2.5-flash output rate.
|
|
1239
|
+
"gemini-3.5-flash": {
|
|
1240
|
+
cached: 15,
|
|
1241
|
+
input: 150,
|
|
1242
|
+
output: 900
|
|
1243
|
+
},
|
|
1244
|
+
// gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
|
|
1245
|
+
// $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
|
|
1246
|
+
// output). A fifth of flash on input, ~a quarter on output. Growth's assistant
|
|
1247
|
+
// ranks the feed on this tier — one call per page — so its rows were the
|
|
1248
|
+
// unpriced ones until now.
|
|
1249
|
+
"gemini-3.5-flash-lite": {
|
|
1250
|
+
cached: 3,
|
|
1251
|
+
input: 30,
|
|
1252
|
+
output: 250
|
|
1253
|
+
},
|
|
1254
|
+
"gemini-2.5-flash": {
|
|
1255
|
+
cached: 3,
|
|
1256
|
+
input: 30,
|
|
1257
|
+
output: 250
|
|
1258
|
+
},
|
|
1259
|
+
"gemini-2.5-flash-image": {
|
|
1260
|
+
cached: 3,
|
|
1261
|
+
input: 30,
|
|
1262
|
+
output: 3e3
|
|
1263
|
+
},
|
|
1264
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
1265
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
1266
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
1267
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
1268
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
1269
|
+
// reports. Growth's hero generation runs this model today.
|
|
1270
|
+
"gemini-3-pro-image-preview": {
|
|
1271
|
+
cached: 20,
|
|
1272
|
+
input: 200,
|
|
1273
|
+
output: 12e3
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
var toolCost = {
|
|
1277
|
+
search: 3.5
|
|
1278
|
+
};
|
|
1279
|
+
var toolPricing = Object.fromEntries(
|
|
1280
|
+
Object.entries(toolCost).map(([tool, value]) => [
|
|
1281
|
+
tool,
|
|
1282
|
+
Math.ceil(value * MARKUP)
|
|
1283
|
+
])
|
|
1284
|
+
);
|
|
1285
|
+
var pricing = Object.fromEntries(
|
|
1286
|
+
Object.entries(cost).map(([model, rates]) => [
|
|
1287
|
+
model,
|
|
1288
|
+
{
|
|
1289
|
+
cached: Math.round(rates.cached * MARKUP),
|
|
1290
|
+
input: Math.round(rates.input * MARKUP),
|
|
1291
|
+
output: Math.round(rates.output * MARKUP)
|
|
1292
|
+
}
|
|
1293
|
+
])
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// lib/pricing.js
|
|
1297
|
+
var emailPlans = {
|
|
1298
|
+
essentials50k: {
|
|
1299
|
+
included: 5e4,
|
|
1300
|
+
// The plan's own price is not shown in the console once you are on it —
|
|
1301
|
+
// it reads "Your Current Plan" where the price would be. Left null rather
|
|
1302
|
+
// than guessed; fill from an invoice. Nothing derives from it yet, and an
|
|
1303
|
+
// invented figure would quietly become the basis of an upgrade decision.
|
|
1304
|
+
monthly: null,
|
|
1305
|
+
overageCents: 0.133,
|
|
1306
|
+
title: "Essentials 50K"
|
|
1307
|
+
},
|
|
1308
|
+
essentials100k: {
|
|
1309
|
+
included: 1e5,
|
|
1310
|
+
monthly: 3495,
|
|
1311
|
+
overageCents: 0.09,
|
|
1312
|
+
title: "Essentials 100K"
|
|
1313
|
+
},
|
|
1314
|
+
pro100k: {
|
|
1315
|
+
included: 1e5,
|
|
1316
|
+
monthly: 8995,
|
|
1317
|
+
overageCents: 0.11,
|
|
1318
|
+
title: "Pro 100K"
|
|
1319
|
+
},
|
|
1320
|
+
pro300k: {
|
|
1321
|
+
included: 3e5,
|
|
1322
|
+
monthly: 24900,
|
|
1323
|
+
overageCents: 0.091,
|
|
1324
|
+
title: "Pro 300K"
|
|
1325
|
+
},
|
|
1326
|
+
pro700k: {
|
|
1327
|
+
included: 7e5,
|
|
1328
|
+
monthly: 49900,
|
|
1329
|
+
overageCents: 0.078,
|
|
1330
|
+
title: "Pro 700K"
|
|
1331
|
+
},
|
|
1332
|
+
pro1500k: {
|
|
1333
|
+
included: 15e5,
|
|
1334
|
+
monthly: 79900,
|
|
1335
|
+
overageCents: 0.059,
|
|
1336
|
+
title: "Pro 1.5 Million"
|
|
1337
|
+
},
|
|
1338
|
+
pro2500k: {
|
|
1339
|
+
included: 25e5,
|
|
1340
|
+
monthly: 109900,
|
|
1341
|
+
overageCents: 0.047,
|
|
1342
|
+
title: "Pro 2.5 Million"
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
var emailPlan = "essentials50k";
|
|
1346
|
+
var sending = {
|
|
1347
|
+
email: {
|
|
1348
|
+
// Fraction of the included volume that raises the alarm. Far enough ahead
|
|
1349
|
+
// to act on, high enough not to fire on ordinary growth.
|
|
1350
|
+
//
|
|
1351
|
+
// Crossing it is NOT an outage. The provider bills overage rather than
|
|
1352
|
+
// stopping sends, so the alarm is a COST signal: past the included volume
|
|
1353
|
+
// every email is charged at overageCents, and at some run rate the next
|
|
1354
|
+
// plan up is cheaper than the overage. That is the decision it exists to
|
|
1355
|
+
// prompt, and it is why nobody should be woken by it.
|
|
1356
|
+
alertThreshold: 0.8,
|
|
1357
|
+
// Emails per calendar month included, PLATFORM-WIDE and across every send
|
|
1358
|
+
// path: lead-facing mail, workflow steps, and the sign-in codes and
|
|
1359
|
+
// account mail that no plan allowance meters.
|
|
1360
|
+
monthlyCeiling: emailPlans[emailPlan].included,
|
|
1361
|
+
overageCents: emailPlans[emailPlan].overageCents,
|
|
1362
|
+
plan: emailPlan,
|
|
1363
|
+
plans: emailPlans,
|
|
1364
|
+
title: emailPlans[emailPlan].title
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
var channels = {
|
|
1368
|
+
email: {
|
|
1369
|
+
actionsPerSend: 1,
|
|
1370
|
+
includedInAllowance: true
|
|
1371
|
+
},
|
|
1372
|
+
sms: {
|
|
1373
|
+
// Two actions PER SEGMENT (a long message is several segments), billed from
|
|
1374
|
+
// the FIRST segment and never drawn from the plan's included allowance —
|
|
1375
|
+
// carrier cost is real from message one, so there is no free tier of it.
|
|
1376
|
+
actionsPerSegment: 2,
|
|
1377
|
+
includedInAllowance: false
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// lib/connections/drawbridge.js
|
|
1382
|
+
var drawbridge_default2 = {
|
|
1383
|
+
auth: {
|
|
1384
|
+
type: "none"
|
|
1385
|
+
},
|
|
1386
|
+
content: {
|
|
1387
|
+
confirm: "This connection is part of Drawbridge and cannot be disconnected.",
|
|
1388
|
+
description: [
|
|
1389
|
+
"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."
|
|
1390
|
+
],
|
|
1391
|
+
excerpt: "The steps Drawbridge runs itself.",
|
|
1392
|
+
guide: [
|
|
1393
|
+
"Nothing to do. These steps are available in every workflow builder."
|
|
1394
|
+
]
|
|
1395
|
+
},
|
|
1396
|
+
exclusive: false,
|
|
1397
|
+
fields: [],
|
|
1398
|
+
group: "developer",
|
|
1399
|
+
// EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
|
|
1400
|
+
// suppression collection and the queues; segment sync needs the streams. A
|
|
1401
|
+
// published package carrying those makes every consumer carry them, which is
|
|
1402
|
+
// the reason `{}` exists as an answer.
|
|
1403
|
+
hooks: {
|
|
1404
|
+
auth: {
|
|
1405
|
+
// Nothing to connect, revoke, probe or re-scope.
|
|
1406
|
+
connect: false,
|
|
1407
|
+
disconnect: false,
|
|
1408
|
+
probe: false,
|
|
1409
|
+
scopes: false,
|
|
1410
|
+
token: false
|
|
1411
|
+
},
|
|
1412
|
+
commerce: false,
|
|
1413
|
+
// DRAWBRIDGE'S OWN CRM. Not a merchant's — this keeps our HubSpot portal in
|
|
1414
|
+
// step with account signups, and drawbridge-sync's user stream calls it.
|
|
1415
|
+
//
|
|
1416
|
+
// A real implementation here rather than `{}` because the bodies are pure
|
|
1417
|
+
// HTTP against a token: no controller, no queue, nothing that would have to
|
|
1418
|
+
// live in the service. lib/hubspot.js holds them, beside lib/sendgrid.js
|
|
1419
|
+
// and lib/twilio.js, which are the same kind of thing — vendor clients for
|
|
1420
|
+
// accounts DRAWBRIDGE holds rather than ones a merchant connects.
|
|
1421
|
+
contacts,
|
|
1422
|
+
email: {
|
|
1423
|
+
digest: {},
|
|
1424
|
+
// To organization members. NEVER suppressed and never billed: an
|
|
1425
|
+
// entrant's opt-out must not silence an alert to staff, and staff mail is
|
|
1426
|
+
// not a metered send.
|
|
1427
|
+
notify: {},
|
|
1428
|
+
// To a lead. Suppression applies and the send is billed.
|
|
1429
|
+
send: {}
|
|
1430
|
+
},
|
|
1431
|
+
inbound: false,
|
|
1432
|
+
lifecycle: false,
|
|
1433
|
+
resources: {
|
|
1434
|
+
audiences: false,
|
|
1435
|
+
prices: false,
|
|
1436
|
+
products: false,
|
|
1437
|
+
promotions: false
|
|
1438
|
+
},
|
|
1439
|
+
segment: { sync: {} },
|
|
1440
|
+
sms: { send: {} },
|
|
1441
|
+
webhook: false
|
|
1442
|
+
},
|
|
1443
|
+
icon: drawbridge_default,
|
|
1444
|
+
// PRIVATE: never in the catalog, always available to the builder.
|
|
1445
|
+
private: true,
|
|
1446
|
+
// NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
|
|
1447
|
+
//
|
|
1448
|
+
// `requires` gates AVAILABILITY: a name in it that is unset removes the whole
|
|
1449
|
+
// connection. This one is private and contributes every base workflow step —
|
|
1450
|
+
// email.send, sms.send, segment.sync — so gating it on a CRM token would take
|
|
1451
|
+
// all of them away from any deployment without a HubSpot portal, to protect a
|
|
1452
|
+
// sync that is best-effort and already no-ops without a token.
|
|
1453
|
+
//
|
|
1454
|
+
// The test 'a vendor is only available when its environment is configured'
|
|
1455
|
+
// caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
|
|
1456
|
+
// empty the moment this was added.
|
|
1457
|
+
requires: [],
|
|
1458
|
+
slug: "drawbridge",
|
|
1459
|
+
// Always on. There is no credential that could go bad and no configuration a
|
|
1460
|
+
// merchant could leave half-finished.
|
|
1461
|
+
status: () => "active",
|
|
1462
|
+
// DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
|
|
1463
|
+
// below — trigger, billable, settings — is what that catalog and the workflow
|
|
1464
|
+
// route already enforce today, because this replaces them rather than
|
|
1465
|
+
// competing with them.
|
|
1466
|
+
//
|
|
1467
|
+
// NOT HERE, deliberately:
|
|
1468
|
+
//
|
|
1469
|
+
// step.segment.sync a SYSTEM step, dispatched by drawbridge-sync rather
|
|
1470
|
+
// than offered in the builder. It fans out, so the shell
|
|
1471
|
+
// opens its step document and the chunks close it.
|
|
1472
|
+
steps: {
|
|
1473
|
+
email: {
|
|
1474
|
+
// SCHEDULE-TRIGGERED, not lead-triggered: it is offered under Daily,
|
|
1475
|
+
// Weekly and Monthly. Those triggers had offered no steps at all, so a
|
|
1476
|
+
// scheduled workflow was selectable and inert until this landed.
|
|
1477
|
+
digest: () => ({
|
|
1478
|
+
hook: "email.digest",
|
|
1479
|
+
key: "Email \u2014 Digest",
|
|
1480
|
+
queue: "notification",
|
|
1481
|
+
settings: {
|
|
1482
|
+
// The organization OWNER is always a recipient, resolved in sync,
|
|
1483
|
+
// so this is additional recipients rather than the list. It cannot
|
|
1484
|
+
// be required: the members endpoint is owner-gated and the owner is
|
|
1485
|
+
// not a member document, so a solo merchant has nothing to pick and
|
|
1486
|
+
// could never save the step.
|
|
1487
|
+
members: { of: "string", type: "array" },
|
|
1488
|
+
message: { required: true, type: "string" },
|
|
1489
|
+
subject: { required: true, type: "string" }
|
|
1490
|
+
},
|
|
1491
|
+
triggers: ["schedule.day", "schedule.week", "schedule.month"],
|
|
1492
|
+
usage: { actions: 0 }
|
|
1493
|
+
}),
|
|
1494
|
+
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
1495
|
+
// not silence an alert to staff — and not billed.
|
|
1496
|
+
notify: () => ({
|
|
1497
|
+
hook: "email.notify",
|
|
1498
|
+
key: "Email \u2014 Notification",
|
|
1499
|
+
queue: "notification",
|
|
1500
|
+
settings: {
|
|
1501
|
+
members: { of: "string", type: "array" },
|
|
1502
|
+
message: { required: true, type: "string" },
|
|
1503
|
+
subject: { required: true, type: "string" }
|
|
1504
|
+
},
|
|
1505
|
+
triggers: ["lead.insert"],
|
|
1506
|
+
// Zero is a PRICE, and a deliberate one. Declared rather than omitted
|
|
1507
|
+
// so "this is free" and "nobody decided" stay different statements;
|
|
1508
|
+
// completeStep bills only when actions > 0.
|
|
1509
|
+
usage: { actions: 0 }
|
|
1510
|
+
}),
|
|
1511
|
+
// To a LEAD. Suppression applies and the send is billed.
|
|
1512
|
+
send: () => ({
|
|
1513
|
+
hook: "email.send",
|
|
1514
|
+
key: "Email \u2014 Send email",
|
|
1515
|
+
queue: "notification",
|
|
1516
|
+
settings: {
|
|
1517
|
+
message: { required: true, type: "string" },
|
|
1518
|
+
subject: { required: true, type: "string" }
|
|
1519
|
+
},
|
|
1520
|
+
triggers: ["lead.insert"],
|
|
1521
|
+
// ONE SOURCE FOR THE PRICE. lib/pricing.js is the index of every
|
|
1522
|
+
// customer-facing number; the handler read it too, so the same fact
|
|
1523
|
+
// was stated in two places and only one of them was reviewed.
|
|
1524
|
+
usage: { actions: channels.email.actionsPerSend }
|
|
1525
|
+
})
|
|
1526
|
+
},
|
|
1527
|
+
// WITHDRAWN, which is a third thing from builder and system: declared,
|
|
1528
|
+
// routed and runnable, but never offered.
|
|
1529
|
+
//
|
|
1530
|
+
// It went when the twilio connection did — a connection-gated step with no
|
|
1531
|
+
// connection to gate on could only ever render permanently disabled. Stored
|
|
1532
|
+
// workflows still carry it, so it must keep running, and enums.step.type
|
|
1533
|
+
// keeps it for the same reason.
|
|
1534
|
+
//
|
|
1535
|
+
// NO TRIGGERS is what keeps it out of the builder: the catalog derives from
|
|
1536
|
+
// triggers, so a step with none is unreachable by a merchant without a
|
|
1537
|
+
// second list saying so.
|
|
1538
|
+
//
|
|
1539
|
+
// Platform SMS returns as a base step the way email did. That is this entry
|
|
1540
|
+
// gaining triggers, not a new one.
|
|
1541
|
+
sms: {
|
|
1542
|
+
send: () => ({
|
|
1543
|
+
hook: "sms.send",
|
|
1544
|
+
key: "Send an SMS",
|
|
1545
|
+
queue: "notification",
|
|
1546
|
+
settings: {
|
|
1547
|
+
message: { required: true, type: "string" },
|
|
1548
|
+
subject: { required: true, type: "string" }
|
|
1549
|
+
},
|
|
1550
|
+
// Priced per SEGMENT and billed from the first, which the send
|
|
1551
|
+
// resolves from the message length. This is the floor.
|
|
1552
|
+
usage: { actions: channels.sms.actionsPerSegment },
|
|
1553
|
+
withdrawn: true
|
|
1554
|
+
})
|
|
1555
|
+
},
|
|
1556
|
+
segment: {
|
|
1557
|
+
// FANS OUT. It evaluates every contact in the organization against every
|
|
1558
|
+
// segment, which is too much for one job — so the hook returns chunks and
|
|
1559
|
+
// the shell defers completion: openStep writes the document with a slot
|
|
1560
|
+
// per chunk, and whichever chunk lands last closes it and resumes the
|
|
1561
|
+
// chain.
|
|
1562
|
+
//
|
|
1563
|
+
// It carries a hook like every other step. An earlier version declared
|
|
1564
|
+
// none, on the theory that fan-out was a second protocol the shell could
|
|
1565
|
+
// not run; it is the same protocol with the ending deferred, and a step
|
|
1566
|
+
// declaring no hook is silently SKIPPED by the runner.
|
|
1567
|
+
sync: () => ({
|
|
1568
|
+
description: "Recalculates segment membership on a daily schedule.",
|
|
1569
|
+
hook: "segment.sync",
|
|
1570
|
+
key: "Segment Sync",
|
|
1571
|
+
queue: "segment",
|
|
1572
|
+
system: true
|
|
1573
|
+
})
|
|
1574
|
+
}
|
|
1575
|
+
},
|
|
1576
|
+
tasks: () => [],
|
|
1577
|
+
title: "Drawbridge"
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
// lib/connections/icons/klaviyo.js
|
|
1581
|
+
var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1582
|
+
<rect width="500" height="500" fill="white"/>
|
|
1583
|
+
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
1584
|
+
</svg>`;
|
|
1585
|
+
|
|
1586
|
+
// lib/connections/klaviyo.js
|
|
1587
|
+
var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
1588
|
+
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
1589
|
+
...payload && { body: JSON.stringify(payload) },
|
|
1590
|
+
headers: {
|
|
1591
|
+
// Bearer, not Klaviyo-API-Key — that header is for private keys, and
|
|
1592
|
+
// sending it with an OAuth token fails in a way that reads like a bad
|
|
1593
|
+
// token rather than a bad scheme.
|
|
1594
|
+
authorization: "Bearer " + token,
|
|
1595
|
+
...payload && { "content-type": "application/json" },
|
|
1596
|
+
// Klaviyo pins its API by DATE. A request without this header is
|
|
1597
|
+
// refused, and one with an old date keeps the response shape that date
|
|
1598
|
+
// shipped with — which is the point: bumping it is a deliberate act
|
|
1599
|
+
// with a changelog to read, not something that drifts under us.
|
|
1600
|
+
revision: "2026-07-15"
|
|
1601
|
+
},
|
|
1602
|
+
method,
|
|
1603
|
+
signal: AbortSignal.timeout(15e3)
|
|
1604
|
+
});
|
|
1605
|
+
if (!response.ok) {
|
|
1606
|
+
throw Object.assign(
|
|
1607
|
+
new Error("Klaviyo refused the request (" + response.status + ")"),
|
|
1608
|
+
{ status: response.status }
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
return response.status === 204 ? null : response.json();
|
|
1612
|
+
};
|
|
1613
|
+
var klaviyo_default2 = {
|
|
1614
|
+
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
1615
|
+
// exchange without a code_verifier matching the challenge the consent
|
|
1616
|
+
// carried. Most vendors treat it as optional hardening; this one does not,
|
|
1617
|
+
// which is why it is a descriptor flag and not a global.
|
|
1618
|
+
//
|
|
1619
|
+
// HTTP Basic on the token endpoint is the other thing Klaviyo does
|
|
1620
|
+
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
1621
|
+
auth: {
|
|
1622
|
+
oauth: {
|
|
1623
|
+
// NAMES the env vars holding OUR application's client. One identity,
|
|
1624
|
+
// every merchant — the token is the merchant's and arrives from their
|
|
1625
|
+
// own consent, which is what stops one organization reading another's
|
|
1626
|
+
// data.
|
|
1627
|
+
//
|
|
1628
|
+
client: {
|
|
1629
|
+
id: "KLAVIYO_OAUTH_CLIENT_ID",
|
|
1630
|
+
secret: "KLAVIYO_OAUTH_CLIENT_SECRET"
|
|
1631
|
+
},
|
|
1632
|
+
// Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
|
|
1633
|
+
// never mentions this at runtime — you discover it when a refresh fails
|
|
1634
|
+
// on a connection nobody touched — so it is declared, and it is why
|
|
1635
|
+
// auth.probe has to run on a schedule rather than only before a call.
|
|
1636
|
+
//
|
|
1637
|
+
// Token lifetime is NOT declared: the vendor states it on every
|
|
1638
|
+
// exchange, and a copy here would be a second answer that goes stale.
|
|
1639
|
+
expiry: 90 * 24 * 60 * 60,
|
|
1640
|
+
pkce: true,
|
|
1641
|
+
// Space separated. accounts:read is required by Klaviyo on every app
|
|
1642
|
+
// and must stay in the list; the rest are what a contact sync needs.
|
|
1643
|
+
scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
|
|
1644
|
+
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
1645
|
+
// the disconnect hook — three vendor addresses, two of them declared,
|
|
1646
|
+
// which is exactly the kind of split that goes unnoticed.
|
|
1647
|
+
urls: {
|
|
1648
|
+
// TWO DIFFERENT HOSTS, and swapping them fails in opposite ways.
|
|
1649
|
+
//
|
|
1650
|
+
// authorize is a page a HUMAN loads, and it lives on www. Pointing it
|
|
1651
|
+
// at a.klaviyo.com — their API host — sends the merchant somewhere
|
|
1652
|
+
// that never renders a consent screen, so the journey stalls with no
|
|
1653
|
+
// error anybody can see.
|
|
1654
|
+
//
|
|
1655
|
+
// token and revoke are server calls and must stay on a.klaviyo.com:
|
|
1656
|
+
// Klaviyo began blocking OAuth token traffic through www on
|
|
1657
|
+
// 2025-03-31, so the mirror image of this mistake breaks the exchange
|
|
1658
|
+
// instead of the consent.
|
|
1659
|
+
authorize: "https://www.klaviyo.com/oauth/authorize",
|
|
1660
|
+
// WHERE THE MERCHANT LANDS — the dashboard, not drawbridge-api. The
|
|
1661
|
+
// `/api/` segment is Next's route-handler convention, which reads as
|
|
1662
|
+
// the api service to everyone who sees it; it is not, and the route
|
|
1663
|
+
// has never moved. build() pins it against the one callback route
|
|
1664
|
+
// that exists, because declared-but-wrong fails AFTER consent — a
|
|
1665
|
+
// 404 for someone who has already granted access.
|
|
1666
|
+
//
|
|
1667
|
+
// Registered in Klaviyo's own app settings, and they refuse anything
|
|
1668
|
+
// that does not byte-match, so it is a fact about someone else's
|
|
1669
|
+
// records rather than a string this code computes.
|
|
1670
|
+
redirect: "/api/connection/klaviyo/callback",
|
|
1671
|
+
revoke: "https://a.klaviyo.com/oauth/revoke",
|
|
1672
|
+
token: "https://a.klaviyo.com/oauth/token"
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
type: "oauth"
|
|
1676
|
+
},
|
|
1677
|
+
// EVERYTHING A MERCHANT READS. Grouped by who it is for rather than by what
|
|
1678
|
+
// kind of sentence it is, so the question on the next vendor is "does a person
|
|
1679
|
+
// read this", which nobody gets wrong, instead of "is this marketing", which
|
|
1680
|
+
// someone will.
|
|
1681
|
+
//
|
|
1682
|
+
// `errors` is in here rather than at the top level, and that is not a
|
|
1683
|
+
// preference. The connection DOCUMENT carries its own `errors` array of
|
|
1684
|
+
// scope-drift entries, and the document is spread OVER the resolved manifest
|
|
1685
|
+
// downstream — so a top-level `errors` here would be silently replaced by that
|
|
1686
|
+
// array and this copy would never render. `fields`/`settings` already carry a
|
|
1687
|
+
// comment about the same collision.
|
|
1688
|
+
content: {
|
|
1689
|
+
// Shown at disconnect, so it says what is lost and what is not.
|
|
1690
|
+
confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
|
|
1691
|
+
description: [
|
|
1692
|
+
"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.",
|
|
1693
|
+
"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.",
|
|
1694
|
+
"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."
|
|
1695
|
+
],
|
|
1696
|
+
// KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
|
|
1697
|
+
// likely to grow — resources.* has already earned somewhere to put "we
|
|
1698
|
+
// could not load your lists" — so a new area adds a key here rather than a
|
|
1699
|
+
// second errors object somewhere else.
|
|
1700
|
+
//
|
|
1701
|
+
// `connect` no longer exists as a container above: its only other member
|
|
1702
|
+
// was `redirect`, which moved to auth.oauth.urls with the rest of the
|
|
1703
|
+
// vendor's addresses.
|
|
1704
|
+
errors: {
|
|
1705
|
+
connect: {
|
|
1706
|
+
denied: "The Klaviyo authorization was declined, so nothing was connected.",
|
|
1707
|
+
invalid: "We couldn't complete the Klaviyo connection. Try connecting again."
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
|
|
1711
|
+
// HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
|
|
1712
|
+
// rendered — four useful instructions no component displayed.
|
|
1713
|
+
guide: [
|
|
1714
|
+
"Press Connect. Drawbridge sends you to Klaviyo to approve access.",
|
|
1715
|
+
"Sign in to Klaviyo if you are not already, and choose the account to connect.",
|
|
1716
|
+
"Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
|
|
1717
|
+
"You can revoke access at any time from Klaviyo, under Integrations."
|
|
1718
|
+
]
|
|
1719
|
+
},
|
|
1720
|
+
// CAN A MERCHANT KEEP TWO OF THESE AT ONCE?
|
|
1721
|
+
//
|
|
1722
|
+
// Required, and false is a decision rather than a default. Shopify is
|
|
1723
|
+
// exclusive because a store maps to exactly one organization. Contact syncs
|
|
1724
|
+
// are destinations — someone can reasonably keep Klaviyo and Mailchimp both
|
|
1725
|
+
// current — so the exclusivity that once applied when these were SENDERS is
|
|
1726
|
+
// deliberately gone. That was removed once already; declaring it out loud is
|
|
1727
|
+
// what stops it coming back by inference.
|
|
1728
|
+
exclusive: false,
|
|
1729
|
+
feature: "organization:connection:klaviyo",
|
|
1730
|
+
fields: [
|
|
1731
|
+
{
|
|
1732
|
+
key: "account",
|
|
1733
|
+
label: "Klaviyo account"
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
// The choices come from the merchant's own account, so the field names
|
|
1737
|
+
// the capability and the client composes the url.
|
|
1738
|
+
hook: "resources.audiences",
|
|
1739
|
+
input: "select",
|
|
1740
|
+
key: "list",
|
|
1741
|
+
label: "Klaviyo list",
|
|
1742
|
+
message: "Contacts your campaigns collect are synced into this list.",
|
|
1743
|
+
required: true,
|
|
1744
|
+
// Klaviyo's list endpoint carries no name filter, so the hook can only
|
|
1745
|
+
// match what it already fetched. A search box that searches one page is
|
|
1746
|
+
// worse than none, so the picker does not offer one.
|
|
1747
|
+
search: false
|
|
1748
|
+
}
|
|
1749
|
+
],
|
|
1750
|
+
// WHAT KIND OF THING THIS IS. One field, not two — `category` said the same
|
|
1751
|
+
// thing and was read by nothing, while `group` was quietly doing double duty
|
|
1752
|
+
// as the mutual-exclusion key. The exclusion moved to `exclusive` above, so
|
|
1753
|
+
// this is purely how a connection is grouped and labelled.
|
|
1754
|
+
group: "contacts",
|
|
1755
|
+
// Nothing typed at connect. The consent returns the grant, and the account it
|
|
1756
|
+
// belongs to is read back from Klaviyo rather than asked for.
|
|
1757
|
+
hooks: {
|
|
1758
|
+
auth: {
|
|
1759
|
+
// Turn a fresh grant into settings worth showing. Without this the card
|
|
1760
|
+
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1761
|
+
// never asked which account they connected — the consent already
|
|
1762
|
+
// decided it, and asking again would be a question we can answer.
|
|
1763
|
+
connect: async ({ fetcher, tokens }) => {
|
|
1764
|
+
var _a, _b, _c;
|
|
1765
|
+
const body = await api("/accounts", { fetcher, token: tokens.accessToken });
|
|
1766
|
+
const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
|
|
1767
|
+
return {
|
|
1768
|
+
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,
|
|
1769
|
+
accountId: (account == null ? void 0 : account.id) || null
|
|
1770
|
+
};
|
|
1771
|
+
},
|
|
1772
|
+
// Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
|
|
1773
|
+
// grant live in the merchant's account, so a disconnect that looks
|
|
1774
|
+
// complete here still shows Drawbridge with access over there.
|
|
1775
|
+
//
|
|
1776
|
+
// Basic auth with our client, exactly like the token exchange — the
|
|
1777
|
+
// token being revoked is the subject, not the credential.
|
|
1778
|
+
disconnect: async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
|
|
1779
|
+
const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
|
|
1780
|
+
if (!token) return { revoked: false };
|
|
1781
|
+
const response = await fetcher(manifest.auth.oauth.urls.revoke, {
|
|
1782
|
+
body: new URLSearchParams({
|
|
1783
|
+
token,
|
|
1784
|
+
token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
|
|
1785
|
+
}),
|
|
1786
|
+
headers: {
|
|
1787
|
+
authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
|
|
1788
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
1789
|
+
},
|
|
1790
|
+
method: "POST",
|
|
1791
|
+
signal: AbortSignal.timeout(15e3)
|
|
1792
|
+
});
|
|
1793
|
+
return { revoked: response.ok };
|
|
1794
|
+
},
|
|
1795
|
+
// THE MINT IS THE PROBE. Asking "is this token still good" by
|
|
1796
|
+
// inspecting what we stored answers the wrong question — a grant
|
|
1797
|
+
// revoked inside Klaviyo still looks perfect in our database. Spending
|
|
1798
|
+
// the refresh token is the only thing that asks Klaviyo.
|
|
1799
|
+
//
|
|
1800
|
+
// It also keeps the grant warm against the 90-day idle window above.
|
|
1801
|
+
probe: async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
|
|
1802
|
+
const token = await accessToken({
|
|
1803
|
+
clientId,
|
|
1804
|
+
clientSecret,
|
|
1805
|
+
fetcher,
|
|
1806
|
+
// Mint even if the stored token still looks good — a probe that
|
|
1807
|
+
// short-circuits never reaches Klaviyo and reports healthy on a
|
|
1808
|
+
// grant revoked an hour ago.
|
|
1809
|
+
force: true,
|
|
1810
|
+
manifest,
|
|
1811
|
+
settings
|
|
1812
|
+
});
|
|
1813
|
+
return { ok: Boolean(token) };
|
|
1814
|
+
},
|
|
1815
|
+
// Klaviyo scopes are fixed at app level and re-consented, not drifted.
|
|
1816
|
+
scopes: false,
|
|
1817
|
+
// KLAVIYO REQUIRES HTTP BASIC on the token endpoint and rejects the same
|
|
1818
|
+
// client_id/client_secret pair as body fields. Everything else about the
|
|
1819
|
+
// request is standard, so this is the shared implementation told the one
|
|
1820
|
+
// thing that differs — in Klaviyo's own file, beside the rest of what
|
|
1821
|
+
// makes Klaviyo unusual, rather than as a flag a caller has to know to
|
|
1822
|
+
// read.
|
|
1823
|
+
token: (args) => authToken({ ...args, basic: true })
|
|
1824
|
+
},
|
|
1825
|
+
// No commerce here. Klaviyo tracks orders, but Drawbridge's order data comes
|
|
1826
|
+
// from the store that took the money — a second source for the same event
|
|
1827
|
+
// is two answers to "did this person buy", and the one we can bill from is
|
|
1828
|
+
// the store's.
|
|
1829
|
+
commerce: false,
|
|
1830
|
+
// The verb the contacts.sync step points at. It does the work — including
|
|
1831
|
+
// writing the profile id back onto the lead — and returns what happened.
|
|
1832
|
+
contacts: {
|
|
1833
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1834
|
+
// different thing from deleting the profile.
|
|
1835
|
+
remove: false,
|
|
1836
|
+
sync: async ({ contact, fetcher, lead, settings, suppressed, token }) => {
|
|
1837
|
+
var _a, _b, _c;
|
|
1838
|
+
const list = settings == null ? void 0 : settings.list;
|
|
1839
|
+
if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
|
|
1840
|
+
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);
|
|
1841
|
+
if (!email) return { message: "That lead has no email address to sync.", skipped: true };
|
|
1842
|
+
const totals = (contact == null ? void 0 : contact.totals) || {};
|
|
1843
|
+
const profile = await api("/profiles/", {
|
|
1844
|
+
fetcher,
|
|
1845
|
+
method: "POST",
|
|
1846
|
+
payload: {
|
|
1847
|
+
data: {
|
|
1848
|
+
attributes: {
|
|
1849
|
+
email,
|
|
1850
|
+
...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
|
|
1851
|
+
properties: {
|
|
1852
|
+
drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
|
|
1853
|
+
drawbridge_draws: totals.draws || 0,
|
|
1854
|
+
drawbridge_entries: totals.entries || 0,
|
|
1855
|
+
drawbridge_orders: totals.orders || 0,
|
|
1856
|
+
// Campaign-attributed, NOT lifetime. A merchant running
|
|
1857
|
+
// Shopify already has lifetime revenue in Klaviyo through
|
|
1858
|
+
// Klaviyo's own integration; what only we can say is how
|
|
1859
|
+
// much a campaign drove. Named so the two cannot be
|
|
1860
|
+
// mistaken for one another in a segment builder.
|
|
1861
|
+
drawbridge_revenue: totals.gross || 0
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
type: "profile"
|
|
1865
|
+
}
|
|
1866
|
+
},
|
|
1867
|
+
token
|
|
1868
|
+
});
|
|
1869
|
+
const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
|
|
1870
|
+
if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
|
|
1871
|
+
await api("/profile-subscription-bulk-create-jobs/", {
|
|
1872
|
+
fetcher,
|
|
1873
|
+
method: "POST",
|
|
1874
|
+
payload: {
|
|
1875
|
+
data: {
|
|
1876
|
+
attributes: {
|
|
1877
|
+
profiles: {
|
|
1878
|
+
data: [{
|
|
1879
|
+
attributes: {
|
|
1880
|
+
email,
|
|
1881
|
+
subscriptions: {
|
|
1882
|
+
email: { marketing: { consent: suppressed ? "UNSUBSCRIBED" : "SUBSCRIBED" } }
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
type: "profile"
|
|
1886
|
+
}]
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
relationships: { list: { data: { id: list, type: "list" } } },
|
|
1890
|
+
type: "profile-subscription-bulk-create-job"
|
|
1891
|
+
}
|
|
1892
|
+
},
|
|
1893
|
+
token
|
|
1894
|
+
});
|
|
1895
|
+
return {
|
|
1896
|
+
// Merged into `context` for later steps in this run.
|
|
1897
|
+
context: { klaviyoProfileId: profileId },
|
|
1898
|
+
message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
|
|
1899
|
+
// Recorded on the run for support to read back, not a write
|
|
1900
|
+
// instruction — the hook has already written what it needed to.
|
|
1901
|
+
response: { klaviyoProfileId: profileId }
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
},
|
|
1905
|
+
// A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
|
|
1906
|
+
// register nothing with it, so listing four falses would be noise around a
|
|
1907
|
+
// single decision. Still explicit — absence would not say whether anybody
|
|
1908
|
+
// considered it.
|
|
1909
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
1910
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
1911
|
+
// these would be a second sender, which is the arrangement the platform
|
|
1912
|
+
// sender replaced.
|
|
1913
|
+
email: false,
|
|
1914
|
+
segment: false,
|
|
1915
|
+
sms: false,
|
|
1916
|
+
inbound: false,
|
|
1917
|
+
// Nothing to set up or tear down at the vendor: the grant is the whole
|
|
1918
|
+
// integration, and revoking it is auth.disconnect's job.
|
|
1919
|
+
lifecycle: false,
|
|
1920
|
+
resources: {
|
|
1921
|
+
// The lists a merchant can sync into, for the picker on their
|
|
1922
|
+
// connection.
|
|
1923
|
+
//
|
|
1924
|
+
// PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to
|
|
1925
|
+
// it, so one call quietly returns the first ten lists and an account
|
|
1926
|
+
// with more shows a picker missing the one they wanted, with nothing to
|
|
1927
|
+
// indicate anything was cut.
|
|
1928
|
+
audiences: async ({ cursor, fetcher, limit = 100, search, token }) => {
|
|
1929
|
+
var _a, _b;
|
|
1930
|
+
const audiences = [];
|
|
1931
|
+
let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
|
|
1932
|
+
let pages = 0;
|
|
1933
|
+
while (next && audiences.length < limit && pages < 20) {
|
|
1934
|
+
const body = await api(next, { fetcher, token });
|
|
1935
|
+
for (const list of (body == null ? void 0 : body.data) || []) {
|
|
1936
|
+
audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
|
|
1937
|
+
}
|
|
1938
|
+
const link = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
|
|
1939
|
+
next = link ? String(link).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
|
|
1940
|
+
pages = pages + 1;
|
|
1941
|
+
}
|
|
1942
|
+
const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
|
|
1943
|
+
return {
|
|
1944
|
+
items: term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences,
|
|
1945
|
+
pageInfo: {
|
|
1946
|
+
endCursor: next,
|
|
1947
|
+
hasNextPage: Boolean(next)
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
},
|
|
1951
|
+
// Klaviyo sells no products and mints no discount codes.
|
|
1952
|
+
prices: false,
|
|
1953
|
+
products: false,
|
|
1954
|
+
promotions: false
|
|
1955
|
+
},
|
|
1956
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
1957
|
+
webhook: false
|
|
1958
|
+
},
|
|
1959
|
+
icon: klaviyo_default,
|
|
1960
|
+
requires: [
|
|
1961
|
+
"KLAVIYO_OAUTH_CLIENT_ID",
|
|
1962
|
+
"KLAVIYO_OAUTH_CLIENT_SECRET"
|
|
1963
|
+
],
|
|
1964
|
+
slug: "klaviyo",
|
|
1965
|
+
// ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
|
|
1966
|
+
// is already the merchant-facing copy channel and is already rendered.
|
|
1967
|
+
//
|
|
1968
|
+
// A grant with no list chosen is authenticated and useless. The list cannot be
|
|
1969
|
+
// part of the consent flow — enumerating lists needs the token the consent
|
|
1970
|
+
// returns — so it is always a second step, and the card must say Pending
|
|
1971
|
+
// rather than Active over nothing.
|
|
1972
|
+
//
|
|
1973
|
+
// Otherwise the credential's own verdict stands. A manifest can only ever
|
|
1974
|
+
// DOWNGRADE: it can see the settings, and it cannot see whether the grant was
|
|
1975
|
+
// revoked at Klaviyo an hour ago.
|
|
1976
|
+
//
|
|
1977
|
+
// Computed at read time rather than written, for the same reason
|
|
1978
|
+
// shopifyMissingScopes is: it becomes true the moment a merchant clears the
|
|
1979
|
+
// list, without waiting for something to notice and write it down.
|
|
1980
|
+
status: (data2) => {
|
|
1981
|
+
var _a;
|
|
1982
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
|
|
1983
|
+
},
|
|
1984
|
+
steps: {
|
|
1985
|
+
contacts: {
|
|
1986
|
+
// A DECLARATION, not the work. It names the hook that does the work, and
|
|
1987
|
+
// says where that hook's values belong. Nested like the hooks, and the
|
|
1988
|
+
// nesting IS the name: this is `step.contacts.sync`, which is what a
|
|
1989
|
+
// workflow document stores.
|
|
1990
|
+
//
|
|
1991
|
+
// A function, so it can depend on what this deployment or this
|
|
1992
|
+
// merchant's connection knows — a static object would have to be true
|
|
1993
|
+
// for every deployment at publish time.
|
|
1994
|
+
sync: ({ data: data2 }) => {
|
|
1995
|
+
var _a;
|
|
1996
|
+
return {
|
|
1997
|
+
hook: "contacts.sync",
|
|
1998
|
+
// The account the merchant actually connected, read back by
|
|
1999
|
+
// auth.connect. The builder reads "Sync contact to Acme Co" rather
|
|
2000
|
+
// than a label that could be any of their Klaviyo accounts.
|
|
2001
|
+
key: "Sync contact to " + (((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.account) || "Klaviyo"),
|
|
2002
|
+
queue: "connection",
|
|
2003
|
+
// Nothing for a merchant to configure on the step itself — the list
|
|
2004
|
+
// is chosen once on the connection. Declared empty rather than
|
|
2005
|
+
// omitted, so "this step takes no settings" and "nobody thought about
|
|
2006
|
+
// settings" are different statements.
|
|
2007
|
+
settings: {},
|
|
2008
|
+
// BOTH triggers. lead.insert alone only ever fires for someone with
|
|
2009
|
+
// no history yet — a brand-new entrant has no orders and no revenue,
|
|
2010
|
+
// so a profile written then carries an email and nothing else.
|
|
2011
|
+
// Crossing into a segment is the moment the ranking data exists.
|
|
2012
|
+
triggers: ["lead.insert", "segment.contact.add"],
|
|
2013
|
+
// One source for cost: what the builder discloses before a merchant
|
|
2014
|
+
// adds this step, and what is charged when it runs.
|
|
2015
|
+
usage: { actions: 1 }
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
},
|
|
2020
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
2021
|
+
tasks: (data2) => {
|
|
2022
|
+
var _a;
|
|
2023
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
|
|
2024
|
+
{
|
|
2025
|
+
message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
|
|
2026
|
+
title: "Choose a list"
|
|
2027
|
+
}
|
|
2028
|
+
];
|
|
2029
|
+
},
|
|
2030
|
+
title: "Klaviyo"
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
// lib/connections/icons/mailchimp.js
|
|
2034
|
+
var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2035
|
+
<rect width="500" height="500" fill="#FFE01B"/>
|
|
2036
|
+
<path d="M310.633 243.561C312.49 243.336 314.249 243.308 315.882 243.561C316.81 241.394 316.979 237.665 316.149 233.598C314.882 227.561 313.18 223.917 309.662 224.508C306.13 225.071 305.989 229.433 307.269 235.47C307.973 238.847 309.24 241.746 310.633 243.561ZM280.392 248.332C282.925 249.429 284.473 250.147 285.05 249.542C285.458 249.148 285.345 248.36 284.74 247.375C283.112 245.042 280.844 243.229 278.211 242.154C275.397 240.982 272.328 240.556 269.301 240.916C266.274 241.275 263.391 242.41 260.93 244.209C259.256 245.447 257.666 247.15 257.863 248.191C257.961 248.515 258.186 248.782 258.777 248.895C260.17 249.049 265.025 246.587 270.64 246.249C274.608 245.968 277.859 247.206 280.392 248.332ZM275.312 251.216C272.047 251.737 270.232 252.807 269.078 253.848C268.079 254.72 267.488 255.649 267.488 256.339L267.741 256.93L268.262 257.127C269.008 257.127 270.668 256.479 270.668 256.479C275.228 254.861 278.253 255.044 281.25 255.382C282.897 255.579 283.671 255.663 284.037 255.1C284.135 254.931 284.29 254.608 283.938 254.059C283.15 252.778 279.843 250.682 275.312 251.216ZM300.416 261.855C302.654 262.953 305.102 262.502 305.904 260.884C306.735 259.266 305.567 257.056 303.329 255.973C301.106 254.861 298.643 255.283 297.841 256.902C297.039 258.52 298.207 260.757 300.416 261.855ZM314.742 249.317C312.94 249.289 311.449 251.259 311.393 253.764C311.35 256.254 312.8 258.281 314.615 258.295C316.43 258.323 317.922 256.339 317.964 253.862C318.006 251.385 316.571 249.359 314.742 249.317ZM193.103 294.108C192.653 293.545 191.907 293.7 191.189 293.897C190.683 293.995 190.12 294.136 189.515 294.122C188.909 294.134 188.308 293.998 187.767 293.726C187.225 293.454 186.757 293.054 186.405 292.56C185.575 291.294 185.617 289.394 186.546 287.241L186.968 286.27C188.431 283.019 190.838 277.559 188.122 272.367C187.234 270.534 185.908 268.948 184.261 267.75C182.614 266.553 180.697 265.78 178.679 265.5C176.772 265.256 174.835 265.469 173.026 266.123C171.218 266.776 169.591 267.85 168.28 269.257C164.284 273.661 163.679 279.684 164.439 281.823C164.734 282.611 165.184 282.822 165.494 282.864C166.169 282.963 167.169 282.456 167.802 280.754L167.999 280.219C168.28 279.318 168.801 277.63 169.659 276.307C170.717 274.689 172.375 273.558 174.267 273.162C176.159 272.766 178.131 273.138 179.749 274.196C182.563 276.039 183.619 279.473 182.423 282.752C181.803 284.455 180.804 287.691 181.015 290.351C181.466 295.74 184.815 297.907 187.77 298.175C190.669 298.273 192.695 296.655 193.216 295.459C193.511 294.713 193.258 294.277 193.103 294.108Z" fill="#231E15"/>
|
|
2037
|
+
<path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
|
|
2038
|
+
</svg>`;
|
|
2039
|
+
|
|
2040
|
+
// lib/connections/mailchimp.js
|
|
2041
|
+
var base = (dc) => {
|
|
2042
|
+
if (!dc) throw new Error("This Mailchimp connection has no data centre stored, so there is no host to call");
|
|
2043
|
+
return "https://" + dc + ".api.mailchimp.com/3.0";
|
|
2044
|
+
};
|
|
2045
|
+
var mailchimp_default2 = {
|
|
2046
|
+
// OAUTH 2, authorization code. Every url below is quoted from
|
|
2047
|
+
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
2048
|
+
// than remembered.
|
|
2049
|
+
//
|
|
2050
|
+
// THE METADATA CALL IS MAILCHIMP'S QUIRK and cannot be skipped: an access
|
|
2051
|
+
// token alone cannot call the Marketing API, because every account lives
|
|
2052
|
+
// behind a data-centre prefix (us1, us19...) that only GET
|
|
2053
|
+
// login.mailchimp.com/oauth2/metadata returns — and every subsequent request
|
|
2054
|
+
// needs it in the HOST. That is why auth.connect below is a real function:
|
|
2055
|
+
// the standard exchange does not know where to send anything afterwards.
|
|
2056
|
+
//
|
|
2057
|
+
// No PKCE. No scopes — the docs describe none. And no refresh token: "Mailchimp
|
|
2058
|
+
// Marketing access tokens do not expire, so you don't need to use a
|
|
2059
|
+
// refresh_token", so tokenSettings stores no expiry and isStale reads that as
|
|
2060
|
+
// nothing to refresh toward.
|
|
2061
|
+
//
|
|
2062
|
+
// auth.token stays false: the exchange is POST form-encoded with grant_type,
|
|
2063
|
+
// client_id, client_secret, redirect_uri and code, which is exactly the
|
|
2064
|
+
// runner's default — nothing to wrap.
|
|
2065
|
+
auth: {
|
|
2066
|
+
oauth: {
|
|
2067
|
+
client: {
|
|
2068
|
+
id: "MAILCHIMP_OAUTH_CLIENT_ID",
|
|
2069
|
+
secret: "MAILCHIMP_OAUTH_CLIENT_SECRET"
|
|
2070
|
+
},
|
|
2071
|
+
urls: {
|
|
2072
|
+
authorize: "https://login.mailchimp.com/oauth2/authorize",
|
|
2073
|
+
redirect: "/api/connection/mailchimp/callback",
|
|
2074
|
+
token: "https://login.mailchimp.com/oauth2/token"
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
type: "oauth"
|
|
2078
|
+
},
|
|
2079
|
+
// EVERYTHING A MERCHANT READS. `errors` belongs in here rather than at the
|
|
2080
|
+
// top level because the connection DOCUMENT carries its own `errors` array
|
|
2081
|
+
// and the document is spread OVER the resolved manifest downstream — a
|
|
2082
|
+
// top-level one would be replaced by that array and never render.
|
|
2083
|
+
content: {
|
|
2084
|
+
confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
|
|
2085
|
+
description: [
|
|
2086
|
+
"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.",
|
|
2087
|
+
"This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so connecting today does nothing except choose the audience it will use when it ships."
|
|
2088
|
+
],
|
|
2089
|
+
excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
|
|
2090
|
+
guide: [
|
|
2091
|
+
"Press Connect. Drawbridge sends you to Mailchimp to approve access.",
|
|
2092
|
+
"Sign in to Mailchimp if you are not already, and choose the account to connect.",
|
|
2093
|
+
"You come back here to pick the audience your contacts should sync into."
|
|
2094
|
+
]
|
|
2095
|
+
},
|
|
2096
|
+
// Mailchimp and SendGrid shared a group while they were SENDERS, where an org
|
|
2097
|
+
// picking two providers to send the same mail was meaningless. As contact
|
|
2098
|
+
// syncs they are destinations, and a merchant could reasonably keep several
|
|
2099
|
+
// up to date, so the exclusivity is gone.
|
|
2100
|
+
exclusive: false,
|
|
2101
|
+
feature: "organization:connection:mailchimp",
|
|
2102
|
+
fields: [
|
|
2103
|
+
{
|
|
2104
|
+
input: "select",
|
|
2105
|
+
key: "audience",
|
|
2106
|
+
label: "Mailchimp audience",
|
|
2107
|
+
message: "Contacts your campaigns collect are synced into this audience.",
|
|
2108
|
+
hook: "resources.audiences",
|
|
2109
|
+
required: true,
|
|
2110
|
+
// Mailchimp's /lists takes no name filter either — same reason.
|
|
2111
|
+
search: false
|
|
2112
|
+
}
|
|
2113
|
+
],
|
|
2114
|
+
group: "contacts",
|
|
2115
|
+
// A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
|
|
2116
|
+
// else is built yet, because audience sync has not shipped. Every false here
|
|
2117
|
+
// is "not yet" rather than "never" — when the sync lands, probe and
|
|
2118
|
+
// contacts.sync are the first to flip.
|
|
2119
|
+
hooks: {
|
|
2120
|
+
auth: {
|
|
2121
|
+
// WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
|
|
2122
|
+
// is unusable, because the Marketing API host is per-account and only
|
|
2123
|
+
// this call knows it. The callback merges what this returns into the
|
|
2124
|
+
// stored settings, which is how `dc` reaches every later request.
|
|
2125
|
+
//
|
|
2126
|
+
// The header here is `OAuth <token>`, not Bearer — that is specific to
|
|
2127
|
+
// the metadata endpoint. Marketing API calls take Bearer; see the
|
|
2128
|
+
// audiences hook.
|
|
2129
|
+
connect: async ({ fetcher = fetch, tokens }) => {
|
|
2130
|
+
const response = await fetcher("https://login.mailchimp.com/oauth2/metadata", {
|
|
2131
|
+
headers: {
|
|
2132
|
+
authorization: "OAuth " + (tokens == null ? void 0 : tokens.accessToken)
|
|
2133
|
+
},
|
|
2134
|
+
signal: AbortSignal.timeout(15e3)
|
|
2135
|
+
});
|
|
2136
|
+
if (!response.ok) {
|
|
2137
|
+
throw Object.assign(
|
|
2138
|
+
new Error("Mailchimp would not say which data centre this account is on (" + response.status + ")"),
|
|
2139
|
+
{ status: response.status }
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
const body = await response.json();
|
|
2143
|
+
if (!(body == null ? void 0 : body.dc)) throw new Error("Mailchimp returned no data centre for this account");
|
|
2144
|
+
return { dc: body.dc };
|
|
2145
|
+
},
|
|
2146
|
+
// Nothing to call. A merchant revokes Drawbridge from Mailchimp's own
|
|
2147
|
+
// Authorized Apps page; the docs describe no revocation endpoint for us
|
|
2148
|
+
// to call on their behalf.
|
|
2149
|
+
disconnect: false,
|
|
2150
|
+
probe: false,
|
|
2151
|
+
scopes: false,
|
|
2152
|
+
// The plain exchange. Mailchimp takes the client as FORM FIELDS
|
|
2153
|
+
// (grant_type, client_id, client_secret, redirect_uri, code), which is
|
|
2154
|
+
// the runner's default — so no `basic : true` as Klaviyo needs.
|
|
2155
|
+
//
|
|
2156
|
+
// build() requires an oauth manifest to name this explicitly rather than
|
|
2157
|
+
// letting it default, which caught this file declaring `false` on the
|
|
2158
|
+
// first import after the conversion.
|
|
2159
|
+
token: authToken
|
|
2160
|
+
},
|
|
2161
|
+
commerce: false,
|
|
2162
|
+
contacts: { remove: false, sync: false },
|
|
2163
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2164
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2165
|
+
// these would be a second sender, which is the arrangement the platform
|
|
2166
|
+
// sender replaced.
|
|
2167
|
+
email: false,
|
|
2168
|
+
segment: false,
|
|
2169
|
+
sms: false,
|
|
2170
|
+
inbound: false,
|
|
2171
|
+
lifecycle: false,
|
|
2172
|
+
resources: {
|
|
2173
|
+
// The audiences a merchant can sync into, for the picker on their
|
|
2174
|
+
// connection.
|
|
2175
|
+
//
|
|
2176
|
+
// count DEFAULTS TO 10 and maxes at 1000 (Mailchimp's own OpenAPI spec),
|
|
2177
|
+
// so leaving it unset returns the first ten audiences and looks entirely
|
|
2178
|
+
// successful — the same silent truncation Klaviyo has, at a different
|
|
2179
|
+
// number. Paged against total_items so an account past a thousand still
|
|
2180
|
+
// resolves.
|
|
2181
|
+
audiences: async ({ cursor, fetcher = fetch, limit = 100, search, settings, token }) => {
|
|
2182
|
+
const dc = settings == null ? void 0 : settings.dc;
|
|
2183
|
+
const count = Math.min(limit, 1e3);
|
|
2184
|
+
const offset = Number(cursor || 0);
|
|
2185
|
+
const response = await fetcher(
|
|
2186
|
+
base(dc) + "/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
|
|
2187
|
+
{
|
|
2188
|
+
headers: { authorization: "Bearer " + token },
|
|
2189
|
+
signal: AbortSignal.timeout(15e3)
|
|
2190
|
+
}
|
|
2191
|
+
);
|
|
2192
|
+
if (!response.ok) {
|
|
2193
|
+
throw Object.assign(
|
|
2194
|
+
new Error("Mailchimp refused the request (" + response.status + ")"),
|
|
2195
|
+
{ status: response.status }
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
const body = await response.json();
|
|
2199
|
+
const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
|
|
2200
|
+
const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
|
|
2201
|
+
const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
|
|
2202
|
+
const nextOffset = offset + count;
|
|
2203
|
+
const more = nextOffset < Number((body == null ? void 0 : body.total_items) || 0);
|
|
2204
|
+
return {
|
|
2205
|
+
items,
|
|
2206
|
+
pageInfo: {
|
|
2207
|
+
endCursor: more ? String(nextOffset) : null,
|
|
2208
|
+
hasNextPage: more
|
|
2209
|
+
}
|
|
2210
|
+
};
|
|
2211
|
+
},
|
|
2212
|
+
prices: false,
|
|
2213
|
+
products: false,
|
|
2214
|
+
promotions: false
|
|
2215
|
+
},
|
|
2216
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
2217
|
+
webhook: false
|
|
2218
|
+
},
|
|
2219
|
+
icon: mailchimp_default,
|
|
2220
|
+
// The OAuth client this deployment registered. Without both, the vendor drops
|
|
2221
|
+
// out of availableConnections rather than offering a Connect button that
|
|
2222
|
+
// cannot complete.
|
|
2223
|
+
requires: [
|
|
2224
|
+
"MAILCHIMP_OAUTH_CLIENT_ID",
|
|
2225
|
+
"MAILCHIMP_OAUTH_CLIENT_SECRET"
|
|
2226
|
+
],
|
|
2227
|
+
slug: "mailchimp",
|
|
2228
|
+
// A key with no audience chosen is authenticated and inert. Mailchimp also
|
|
2229
|
+
// needs its merge fields created on that audience before any Drawbridge total
|
|
2230
|
+
// can be written to a member — unlike Klaviyo, its custom fields are not
|
|
2231
|
+
// schemaless — so the audience must be picked before lifecycle.register has
|
|
2232
|
+
// anything to register against.
|
|
2233
|
+
status: (data2) => {
|
|
2234
|
+
var _a;
|
|
2235
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? data2.status : "pending";
|
|
2236
|
+
},
|
|
2237
|
+
// No steps: audience sync has not shipped, so this vendor contributes nothing
|
|
2238
|
+
// to a workflow yet. An empty steps object is the honest declaration — the
|
|
2239
|
+
// catalog renders the connection, and no builder offers a step it cannot run.
|
|
2240
|
+
steps: {},
|
|
2241
|
+
tasks: (data2) => {
|
|
2242
|
+
var _a;
|
|
2243
|
+
return [
|
|
2244
|
+
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
|
|
2245
|
+
{
|
|
2246
|
+
message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
|
|
2247
|
+
title: "Choose an audience"
|
|
2248
|
+
}
|
|
2249
|
+
],
|
|
2250
|
+
{
|
|
2251
|
+
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.",
|
|
2252
|
+
title: "Audience sync not available yet",
|
|
2253
|
+
type: "warning"
|
|
2254
|
+
}
|
|
2255
|
+
];
|
|
2256
|
+
},
|
|
2257
|
+
title: "Mailchimp"
|
|
2258
|
+
};
|
|
2259
|
+
|
|
2260
|
+
// lib/connections/icons/shopify.js
|
|
2261
|
+
var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2262
|
+
<rect width="500" height="500" fill="white"/>
|
|
2263
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M309.524 407.192L308.799 128.423C306.921 126.545 303.258 127.112 301.827 127.531L292.29 130.487C291.113 126.613 289.585 122.854 287.726 119.258C280.959 106.337 271.069 99.5052 259.096 99.4866H259.059C258.259 99.4866 257.469 99.5609 256.67 99.626L256.577 99.6353C256.231 99.2089 255.871 98.7935 255.499 98.3897C250.293 92.8125 243.601 90.0889 235.588 90.3213C220.139 90.7675 204.755 101.941 192.271 121.786C183.487 135.757 176.823 153.298 174.917 166.878L144.493 176.313C135.542 179.129 135.263 179.408 134.082 187.858C133.199 194.253 109.766 375.69 109.766 375.69L306.159 409.692L309.524 407.192ZM245.181 103.065C242.569 101.346 239.511 100.546 235.885 100.621C212.033 101.308 191.23 138.611 185.923 163.467L208.771 156.384L212.851 155.119C215.845 139.336 223.355 122.957 233.181 112.416C236.616 108.639 240.671 105.477 245.172 103.065H245.181ZM224.145 151.615L256.94 141.446C257.042 132.894 256.112 120.252 251.836 111.329C247.282 113.207 243.452 116.497 240.7 119.444C233.329 127.373 227.315 139.466 224.155 151.615H224.145ZM267.211 138.267L282.455 133.536C280.02 125.616 274.238 112.342 262.517 110.111C266.161 119.527 267.099 130.431 267.211 138.267Z" fill="#95BF47"/>
|
|
2264
|
+
<path d="M353.528 149.156C352.356 149.063 329.657 148.709 329.657 148.709C329.657 148.709 310.666 130.249 308.789 128.362C308.062 127.691 307.141 127.268 306.158 127.153V409.64L391.257 388.456C391.257 388.456 356.53 153.366 356.307 151.758C356.199 151.075 355.866 150.448 355.361 149.976C354.856 149.505 354.216 149.216 353.528 149.156Z" fill="#5E8E3E"/>
|
|
2265
|
+
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
2266
|
+
</svg>`;
|
|
2267
|
+
|
|
2268
|
+
// lib/connections/inbound.js
|
|
2269
|
+
var import_node_crypto2 = require("crypto");
|
|
2270
|
+
var verifySignature = ({ body, descriptor, headers }) => {
|
|
2271
|
+
const provided = headers[descriptor.headers.signature];
|
|
2272
|
+
if (!provided) {
|
|
2273
|
+
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
2274
|
+
}
|
|
2275
|
+
const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
|
|
2276
|
+
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
2277
|
+
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
2278
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
2279
|
+
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
2280
|
+
}
|
|
2281
|
+
return JSON.parse(body.toString());
|
|
2282
|
+
};
|
|
2283
|
+
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
2284
|
+
|
|
2285
|
+
// lib/connections/shopify.js
|
|
2286
|
+
var inbound = {
|
|
2287
|
+
headers: {
|
|
2288
|
+
event: "x-shopify-topic",
|
|
2289
|
+
id: "x-shopify-webhook-id",
|
|
2290
|
+
shop: "x-shopify-shop-domain",
|
|
2291
|
+
signature: "x-shopify-hmac-sha256"
|
|
2292
|
+
},
|
|
2293
|
+
signature: {
|
|
2294
|
+
algorithm: "sha256",
|
|
2295
|
+
encoding: "base64",
|
|
2296
|
+
secret: "SHOPIFY_API_SECRET"
|
|
2297
|
+
}
|
|
2298
|
+
};
|
|
2299
|
+
var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
|
|
2300
|
+
"customers/data_request",
|
|
2301
|
+
"customers/redact",
|
|
2302
|
+
"shop/redact"
|
|
2303
|
+
]);
|
|
2304
|
+
var shopify_default2 = {
|
|
2305
|
+
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
2306
|
+
// sees a consent screen we sent them to -- they start at the App Store, and
|
|
2307
|
+
// the install completes inside Shopify admin without redirecting back. A
|
|
2308
|
+
// Connect button here would be lying about where connecting happens.
|
|
2309
|
+
auth: {
|
|
2310
|
+
type: "install"
|
|
2311
|
+
},
|
|
2312
|
+
// EVERYTHING A MERCHANT READS.
|
|
2313
|
+
//
|
|
2314
|
+
// `errors` is in here rather than at the top level, and that is not a
|
|
2315
|
+
// preference: the connection DOCUMENT carries its own `errors` array of
|
|
2316
|
+
// scope-drift entries, and the document is spread OVER the resolved manifest
|
|
2317
|
+
// downstream — a top-level one would be replaced by that array and never
|
|
2318
|
+
// render.
|
|
2319
|
+
//
|
|
2320
|
+
// `connect` no longer exists as a container. Its other member was `redirect`,
|
|
2321
|
+
// which is a URL and now sits with the vendor's other addresses.
|
|
2322
|
+
content: {
|
|
2323
|
+
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.",
|
|
2324
|
+
description: [
|
|
2325
|
+
"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.",
|
|
2326
|
+
"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.",
|
|
2327
|
+
"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."
|
|
2328
|
+
],
|
|
2329
|
+
errors: {
|
|
2330
|
+
connect: {
|
|
2331
|
+
conflict: "This store is already connected to another Drawbridge organization.",
|
|
2332
|
+
currency: "This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.",
|
|
2333
|
+
invalid: "We couldn't verify the install. Please try connecting again from the Shopify App Store."
|
|
2334
|
+
}
|
|
2335
|
+
},
|
|
2336
|
+
excerpt: "Connect your Shopify store to feature products in your campaigns and track conversions.",
|
|
2337
|
+
guide: [
|
|
2338
|
+
"Open the Drawbridge listing on the Shopify App Store.",
|
|
2339
|
+
"Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
|
|
2340
|
+
"Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
|
|
2341
|
+
"Come back here \u2014 the connections list updates on its own once the install lands."
|
|
2342
|
+
],
|
|
2343
|
+
// Names where the link GOES rather than what it does: installing happens on
|
|
2344
|
+
// the App Store listing, and the dashboard must never imply a store can be
|
|
2345
|
+
// linked from inside it.
|
|
2346
|
+
redirect: {
|
|
2347
|
+
env: "SHOPIFY_APP_LISTING_URL",
|
|
2348
|
+
title: "View on the Shopify App Store"
|
|
2349
|
+
}
|
|
2350
|
+
},
|
|
2351
|
+
// ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
|
|
2352
|
+
// attributed order two possible sources.
|
|
2353
|
+
exclusive: true,
|
|
2354
|
+
feature: "organization:connection:shopify",
|
|
2355
|
+
fields: [
|
|
2356
|
+
{
|
|
2357
|
+
// `shop` on the connection record wins when present — it is written by
|
|
2358
|
+
// the install, while settings.domain is the stored copy.
|
|
2359
|
+
from: "shop",
|
|
2360
|
+
key: "domain",
|
|
2361
|
+
label: "Store domain"
|
|
2362
|
+
}
|
|
2363
|
+
],
|
|
2364
|
+
// Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
|
|
2365
|
+
// fact, which left nobody able to say which one a page read.
|
|
2366
|
+
group: "commerce",
|
|
2367
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
2368
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
2369
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
2370
|
+
// channel: /events buffers whatever arrives with the shop domain stamped on;
|
|
2371
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
2372
|
+
// is a legal deadline rather than a retry.
|
|
2373
|
+
hooks: {
|
|
2374
|
+
auth: {
|
|
2375
|
+
// The install completes inside Shopify admin; the api's callback stores
|
|
2376
|
+
// what it hands back. auth.probe is false deliberately: the health check
|
|
2377
|
+
// re-registers rather than answering "is this token still good", and
|
|
2378
|
+
// scope drift is its own hook because a token can be perfectly valid
|
|
2379
|
+
// while the grant is too narrow.
|
|
2380
|
+
//
|
|
2381
|
+
// connect and disconnect are FALSE rather than `{}`: there is nothing to
|
|
2382
|
+
// call on either side. The install already hands the callback everything
|
|
2383
|
+
// it stores, and a Shopify grant is withdrawn by UNINSTALLING the app in
|
|
2384
|
+
// Shopify admin — which Drawbridge learns about from the app_uninstalled
|
|
2385
|
+
// webhook rather than by asking. `{}` claimed a body implemented
|
|
2386
|
+
// elsewhere; none exists, and none could.
|
|
2387
|
+
connect: false,
|
|
2388
|
+
disconnect: false,
|
|
2389
|
+
probe: false,
|
|
2390
|
+
// WHETHER THE GRANT IS STILL WIDE ENOUGH. A token can be perfectly valid
|
|
2391
|
+
// and still too narrow — a deploy that adds a scope leaves every existing
|
|
2392
|
+
// install short of it, and no webhook fires to say so.
|
|
2393
|
+
//
|
|
2394
|
+
// The comparison is the vendor's, so it belongs here. Reading WHICH
|
|
2395
|
+
// scopes a store granted is not: that lives in the `shop` collection and
|
|
2396
|
+
// needs a controller, which is precisely what a hook in a published
|
|
2397
|
+
// package must not be handed. The caller reads the grant and passes the
|
|
2398
|
+
// string; this answers what is missing from it.
|
|
2399
|
+
//
|
|
2400
|
+
// `shopify` is injected for the same reason it is everywhere else — this
|
|
2401
|
+
// package cannot import @drawbridge/shopify, which depends on it.
|
|
2402
|
+
scopes: ({ scope, shopify }) => scope ? shopify.oauth.missingScopes(scope) : null,
|
|
2403
|
+
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2404
|
+
// through the shared OAuth runner.
|
|
2405
|
+
token: false
|
|
2406
|
+
},
|
|
2407
|
+
// Implemented in drawbridge-sync, which owns the attribution and the
|
|
2408
|
+
// controllers it needs. Declared here so the steps below can point at them:
|
|
2409
|
+
// a step naming a hook the vendor does not implement is a workflow that
|
|
2410
|
+
// accepts the step and then silently does nothing.
|
|
2411
|
+
commerce: {
|
|
2412
|
+
code: {},
|
|
2413
|
+
customer: {},
|
|
2414
|
+
order: {},
|
|
2415
|
+
product: {}
|
|
2416
|
+
},
|
|
2417
|
+
contacts: { remove: false, sync: false },
|
|
2418
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's
|
|
2419
|
+
// scheme is exactly the shape it covers, so there is nothing vendor-specific
|
|
2420
|
+
// to write for either. receive is the one hook that genuinely differs by
|
|
2421
|
+
// channel: /events buffers whatever arrives with the shop domain stamped on;
|
|
2422
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
2423
|
+
// is a legal deadline rather than a retry.
|
|
2424
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2425
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2426
|
+
// these would be a second sender, which is the arrangement the platform
|
|
2427
|
+
// sender replaced.
|
|
2428
|
+
email: false,
|
|
2429
|
+
segment: false,
|
|
2430
|
+
sms: false,
|
|
2431
|
+
inbound: {
|
|
2432
|
+
event: (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
2433
|
+
process: {},
|
|
2434
|
+
receive: ({ channel, event, headers, payload }) => {
|
|
2435
|
+
if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
2436
|
+
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
2437
|
+
}
|
|
2438
|
+
return {
|
|
2439
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
2440
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
2441
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
2442
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
2443
|
+
data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
|
|
2444
|
+
provider: { id: headers[inbound.headers.id] || null }
|
|
2445
|
+
};
|
|
2446
|
+
},
|
|
2447
|
+
verify: (args) => verifySignature({ ...args, descriptor: inbound })
|
|
2448
|
+
},
|
|
2449
|
+
lifecycle: { cleanup: {}, health: {}, register: {}, rehydrate: {} },
|
|
2450
|
+
resources: {
|
|
2451
|
+
audiences: false,
|
|
2452
|
+
// Shopify has no separate price resource — a price belongs to a product
|
|
2453
|
+
// variant and arrives with it, so there is nothing for prices to answer
|
|
2454
|
+
// that products does not already.
|
|
2455
|
+
prices: false,
|
|
2456
|
+
// WHAT THE VENDOR ANSWERS, shaped for a picker. Both of these were api
|
|
2457
|
+
// ROUTES — /organization/:organization/shopify/products and
|
|
2458
|
+
// .../connection/:id/shopify/discounts — vendor-named urls in a service
|
|
2459
|
+
// that is supposed to have none, reachable only by knowing the path.
|
|
2460
|
+
// They are the same two questions every other vendor answers through
|
|
2461
|
+
// resources.*, so they answer them the same way now.
|
|
2462
|
+
//
|
|
2463
|
+
// `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
|
|
2464
|
+
// which depends on it. What arrives is the SDK's pure HTTP namespaces
|
|
2465
|
+
// and nothing else — no controller, no collection access. Resolving the
|
|
2466
|
+
// credential is the caller's job because it is Drawbridge's job: the
|
|
2467
|
+
// admin token refreshes and writes itself back, which is service work,
|
|
2468
|
+
// not vendor work.
|
|
2469
|
+
products: async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
|
|
2470
|
+
var _a, _b, _c, _d;
|
|
2471
|
+
const products = await shopify.storefront.getProducts({
|
|
2472
|
+
cursor,
|
|
2473
|
+
domain: settings == null ? void 0 : settings.domain,
|
|
2474
|
+
limit: Number(limit),
|
|
2475
|
+
search: (search == null ? void 0 : search.value) || null,
|
|
2476
|
+
sort,
|
|
2477
|
+
storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
|
|
2478
|
+
});
|
|
2479
|
+
return {
|
|
2480
|
+
items: ((products == null ? void 0 : products.edges) || []).map((edge) => edge.node),
|
|
2481
|
+
pageInfo: {
|
|
2482
|
+
endCursor: ((_a = products == null ? void 0 : products.pageInfo) == null ? void 0 : _a.endCursor) || null,
|
|
2483
|
+
hasNextPage: Boolean((_b = products == null ? void 0 : products.pageInfo) == null ? void 0 : _b.hasNextPage),
|
|
2484
|
+
hasPreviousPage: Boolean((_c = products == null ? void 0 : products.pageInfo) == null ? void 0 : _c.hasPreviousPage),
|
|
2485
|
+
startCursor: ((_d = products == null ? void 0 : products.pageInfo) == null ? void 0 : _d.startCursor) || null
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
},
|
|
2489
|
+
promotions: async ({ cursor, limit = 100, search, settings, shopify }) => {
|
|
2490
|
+
var _a, _b;
|
|
2491
|
+
const discounts = await shopify.admin.getDiscounts({
|
|
2492
|
+
adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
|
|
2493
|
+
cursor,
|
|
2494
|
+
domain: settings == null ? void 0 : settings.domain,
|
|
2495
|
+
limit: Number(limit),
|
|
2496
|
+
search: (search == null ? void 0 : search.value) || null
|
|
2497
|
+
});
|
|
2498
|
+
return {
|
|
2499
|
+
// The GLOBAL id is what Shopify returns and the bare id is what a
|
|
2500
|
+
// picker stores, which is why the tail is taken here rather than by
|
|
2501
|
+
// each caller that happened to remember.
|
|
2502
|
+
items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
|
|
2503
|
+
var _a2, _b2, _c;
|
|
2504
|
+
return {
|
|
2505
|
+
id: String(((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id) || "").split("/").pop(),
|
|
2506
|
+
title: (_c = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.codeDiscount) == null ? void 0 : _c.title
|
|
2507
|
+
};
|
|
2508
|
+
}),
|
|
2509
|
+
pageInfo: {
|
|
2510
|
+
endCursor: ((_a = discounts == null ? void 0 : discounts.pageInfo) == null ? void 0 : _a.endCursor) || null,
|
|
2511
|
+
hasNextPage: Boolean((_b = discounts == null ? void 0 : discounts.pageInfo) == null ? void 0 : _b.hasNextPage)
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
},
|
|
2516
|
+
// Drawbridge posts to a merchant's own endpoint, never through a vendor.
|
|
2517
|
+
webhook: false
|
|
2518
|
+
},
|
|
2519
|
+
icon: shopify_default,
|
|
2520
|
+
inbound,
|
|
2521
|
+
// THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
|
|
2522
|
+
//
|
|
2523
|
+
// Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
|
|
2524
|
+
// in the shared resolver — a hardcoded vendor branch in code every vendor runs
|
|
2525
|
+
// through, which is the arrangement these manifests exist to remove.
|
|
2526
|
+
//
|
|
2527
|
+
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
2528
|
+
// connected connection. The app handle is NAMED by `requires` and read from
|
|
2529
|
+
// the env the resolver passes, never from process.env here.
|
|
2530
|
+
manage: (data2, env) => {
|
|
2531
|
+
var _a;
|
|
2532
|
+
const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
|
|
2533
|
+
return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
|
|
2534
|
+
},
|
|
2535
|
+
// A pre-launch integration: it only surfaces once the App Store listing
|
|
2536
|
+
// exists and the app is fully configured. Requiring all four means it can
|
|
2537
|
+
// never render half-configured — and absence of any one excludes the
|
|
2538
|
+
// connection AND every step below it.
|
|
2539
|
+
requires: [
|
|
2540
|
+
"SHOPIFY_API_KEY",
|
|
2541
|
+
"SHOPIFY_API_SECRET",
|
|
2542
|
+
"SHOPIFY_APP_LISTING_URL",
|
|
2543
|
+
"SHOPIFY_APP_HANDLE"
|
|
2544
|
+
],
|
|
2545
|
+
slug: "shopify",
|
|
2546
|
+
// The install is the whole configuration — Shopify hands back the shop and
|
|
2547
|
+
// there is nothing further to choose. `shop` absent means the install did not
|
|
2548
|
+
// finish, which is a credential problem rather than a setup one, so the
|
|
2549
|
+
// stored status already says so.
|
|
2550
|
+
// Nothing to add — no setting can make this connection unusable, so the
|
|
2551
|
+
// credential's own verdict stands.
|
|
2552
|
+
status: (data2) => data2 == null ? void 0 : data2.status,
|
|
2553
|
+
// Step types name the CAPABILITY, not this vendor. A second store platform
|
|
2554
|
+
// implements the same four commerce steps, and the connection on the step
|
|
2555
|
+
// says which store it runs against — so a merchant sees one "Create
|
|
2556
|
+
// customer", not one per platform. The three connection.* steps are not
|
|
2557
|
+
// commerce at all: any vendor holding a rotating credential needs them.
|
|
2558
|
+
// Step types name the CAPABILITY, not this vendor. A second store platform
|
|
2559
|
+
// implements the same commerce steps, and the connection on the step says
|
|
2560
|
+
// which store it runs against — so a merchant sees one "Create customer", not
|
|
2561
|
+
// one per platform.
|
|
2562
|
+
//
|
|
2563
|
+
// Nested for readability and flattened to the stored name, at whatever depth:
|
|
2564
|
+
// steps.commerce.customer.insert is `step.commerce.customer.insert` on a
|
|
2565
|
+
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2566
|
+
//
|
|
2567
|
+
// EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
|
|
2568
|
+
// The bodies these point at still live in drawbridge-sync; moving them is the
|
|
2569
|
+
// next unit, and commerce.order.record is the one that decides whether the
|
|
2570
|
+
// shape holds — 569 lines and 15 controller calls.
|
|
2571
|
+
steps: {
|
|
2572
|
+
commerce: {
|
|
2573
|
+
code: {
|
|
2574
|
+
issue: () => ({
|
|
2575
|
+
hook: "commerce.code",
|
|
2576
|
+
key: "Issue a discount code",
|
|
2577
|
+
queue: "connection",
|
|
2578
|
+
settings: {
|
|
2579
|
+
discount: {
|
|
2580
|
+
required: true,
|
|
2581
|
+
shape: {
|
|
2582
|
+
id: { required: true, type: "string" }
|
|
2583
|
+
},
|
|
2584
|
+
type: "object"
|
|
2585
|
+
}
|
|
2586
|
+
},
|
|
2587
|
+
triggers: ["lead.insert"],
|
|
2588
|
+
usage: { actions: 1 }
|
|
2589
|
+
})
|
|
2590
|
+
},
|
|
2591
|
+
customer: {
|
|
2592
|
+
insert: () => ({
|
|
2593
|
+
hook: "commerce.customer",
|
|
2594
|
+
key: "Create customer",
|
|
2595
|
+
queue: "connection",
|
|
2596
|
+
settings: {},
|
|
2597
|
+
triggers: ["lead.insert"],
|
|
2598
|
+
usage: { actions: 1 }
|
|
2599
|
+
})
|
|
2600
|
+
},
|
|
2601
|
+
// SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
|
|
2602
|
+
// in the builder, so they carry no trigger and no usage. Declared because
|
|
2603
|
+
// the routing table and the system-workflow descriptions both read here.
|
|
2604
|
+
order: {
|
|
2605
|
+
record: () => ({
|
|
2606
|
+
description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
|
|
2607
|
+
hook: "commerce.order",
|
|
2608
|
+
key: "Shopify Order Tracking",
|
|
2609
|
+
queue: "connection",
|
|
2610
|
+
system: true
|
|
2611
|
+
})
|
|
2612
|
+
},
|
|
2613
|
+
product: {
|
|
2614
|
+
sync: () => ({
|
|
2615
|
+
description: "Syncs Shopify product data on webhook updates.",
|
|
2616
|
+
hook: "commerce.product",
|
|
2617
|
+
key: "Shopify Product Sync",
|
|
2618
|
+
queue: "connection",
|
|
2619
|
+
system: true
|
|
2620
|
+
})
|
|
2621
|
+
}
|
|
2622
|
+
},
|
|
2623
|
+
connection: {
|
|
2624
|
+
// Not a webhook monitor, despite the name it once carried. Webhooks are
|
|
2625
|
+
// declarative — declared in the app's toml, applied by Shopify to every
|
|
2626
|
+
// install — so nothing registers or checks them here. This rotates the
|
|
2627
|
+
// access token before Shopify's idle window closes, and reconciles the
|
|
2628
|
+
// scopes the store granted against the ones the app now needs.
|
|
2629
|
+
health: {
|
|
2630
|
+
check: () => ({
|
|
2631
|
+
description: "Keeps store access working \u2014 refreshes the access token before it goes stale and reports when the store's approved permissions fall behind.",
|
|
2632
|
+
hook: "lifecycle.health",
|
|
2633
|
+
key: "Shopify Connection Health",
|
|
2634
|
+
queue: "connection",
|
|
2635
|
+
system: true
|
|
2636
|
+
})
|
|
2637
|
+
},
|
|
2638
|
+
// Audit-only. The "Shopify Token Activity" system workflow lists these
|
|
2639
|
+
// for descriptive grouping, but its audit step docs are written manually
|
|
2640
|
+
// at OAuth time — the workflow is never dispatched. Routing is declared
|
|
2641
|
+
// defensively so that if it ever IS dispatched, the job lands on a real
|
|
2642
|
+
// queue and the handler lookup misses cleanly instead of throwing
|
|
2643
|
+
// "Unknown step type".
|
|
2644
|
+
token: {
|
|
2645
|
+
exchange: () => ({
|
|
2646
|
+
description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
|
|
2647
|
+
key: "Shopify Token Exchange",
|
|
2648
|
+
queue: "connection",
|
|
2649
|
+
system: true
|
|
2650
|
+
}),
|
|
2651
|
+
refresh: () => ({
|
|
2652
|
+
description: "Records a token rotation. Audit only \u2014 never dispatched.",
|
|
2653
|
+
key: "Shopify Token Refresh",
|
|
2654
|
+
queue: "connection",
|
|
2655
|
+
system: true
|
|
2656
|
+
})
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
},
|
|
2660
|
+
// Shopify sits pending between the install landing and the merchant choosing a
|
|
2661
|
+
// plan, and nothing on our side can move it — so the card says what they need
|
|
2662
|
+
// to go and do rather than showing Pending with no next step.
|
|
2663
|
+
//
|
|
2664
|
+
// Scope drift is NOT here: drawbridge-sync writes it onto the connection
|
|
2665
|
+
// document, and the document's own warnings render beside these.
|
|
2666
|
+
tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
|
|
2667
|
+
{
|
|
2668
|
+
message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
|
|
2669
|
+
title: "Choose a plan in Shopify"
|
|
2670
|
+
}
|
|
2671
|
+
] : [],
|
|
2672
|
+
title: "Shopify"
|
|
2673
|
+
};
|
|
2674
|
+
|
|
2675
|
+
// lib/connections/webhook.js
|
|
2676
|
+
var import_node_crypto3 = __toESM(require("crypto"), 1);
|
|
2677
|
+
|
|
2678
|
+
// lib/safe-http.js
|
|
2679
|
+
var import_dns2 = __toESM(require("dns"), 1);
|
|
2680
|
+
var http2 = __toESM(require("http"), 1);
|
|
2681
|
+
var https2 = __toESM(require("https"), 1);
|
|
2682
|
+
|
|
2683
|
+
// lib/axios.js
|
|
2684
|
+
var import_axios = __toESM(require("axios"), 1);
|
|
2685
|
+
var import_dns = __toESM(require("dns"), 1);
|
|
2686
|
+
var http = __toESM(require("http"), 1);
|
|
2687
|
+
var https = __toESM(require("https"), 1);
|
|
2688
|
+
var import_net = __toESM(require("net"), 1);
|
|
2689
|
+
var dnsLookup = import_dns.default.promises.lookup;
|
|
2690
|
+
var isBlockedIPv4 = (ip) => {
|
|
2691
|
+
const parts = ip.split(".").map(Number);
|
|
2692
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
|
2693
|
+
const [a, b] = parts;
|
|
2694
|
+
if (a === 0) return true;
|
|
2695
|
+
if (a === 10) return true;
|
|
2696
|
+
if (a === 127) return true;
|
|
2697
|
+
if (a === 169 && b === 254) return true;
|
|
2698
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
2699
|
+
if (a === 192 && b === 168) return true;
|
|
2700
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
2701
|
+
if (a === 192 && b === 0) return true;
|
|
2702
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
2703
|
+
if (a === 198 && b === 51) return true;
|
|
2704
|
+
if (a === 203 && b === 0) return true;
|
|
2705
|
+
if (a >= 224) return true;
|
|
2706
|
+
return false;
|
|
2707
|
+
};
|
|
2708
|
+
var isBlockedIPv6 = (ip) => {
|
|
2709
|
+
const lower = ip.toLowerCase();
|
|
2710
|
+
if (lower === "::1" || lower === "::") return true;
|
|
2711
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
2712
|
+
if (/^fe[89ab]/.test(lower)) return true;
|
|
2713
|
+
if (lower.startsWith("ff")) return true;
|
|
2714
|
+
if (lower.startsWith("::ffff:")) {
|
|
2715
|
+
const v4 = lower.slice(7);
|
|
2716
|
+
return isBlockedIPv4(v4);
|
|
2717
|
+
}
|
|
2718
|
+
;
|
|
2719
|
+
return false;
|
|
2720
|
+
};
|
|
2721
|
+
var isBlockedIP = (ip) => {
|
|
2722
|
+
const version = import_net.default.isIP(ip);
|
|
2723
|
+
if (version === 4) return isBlockedIPv4(ip);
|
|
2724
|
+
if (version === 6) return isBlockedIPv6(ip);
|
|
2725
|
+
return true;
|
|
2726
|
+
};
|
|
2727
|
+
var axios = import_axios.default.create({
|
|
2728
|
+
timeout: 3e4,
|
|
2729
|
+
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
|
|
2730
|
+
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
// lib/safe-http.js
|
|
2734
|
+
var DEFAULT_TIMEOUT_MS2 = 15e3;
|
|
2735
|
+
var resolveSafeHost = async (url) => {
|
|
2736
|
+
const parsed = new URL(url);
|
|
2737
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2738
|
+
throw new Error("Only http(s) URLs are allowed");
|
|
2739
|
+
}
|
|
2740
|
+
const records = await import_dns2.default.promises.lookup(parsed.hostname, { all: true });
|
|
2741
|
+
if (!(records == null ? void 0 : records.length)) {
|
|
2742
|
+
throw new Error("Host could not be resolved");
|
|
2743
|
+
}
|
|
2744
|
+
for (const record of records) {
|
|
2745
|
+
if (isBlockedIP(record.address)) {
|
|
2746
|
+
throw new Error("Host resolves to a blocked IP range");
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
return { parsed, records };
|
|
2750
|
+
};
|
|
2751
|
+
var pinnedAgent = async (url) => {
|
|
2752
|
+
const { parsed, records } = await resolveSafeHost(url);
|
|
2753
|
+
const pinned = records[0];
|
|
2754
|
+
const lookup2 = (hostname, options, callback) => {
|
|
2755
|
+
if (options == null ? void 0 : options.all) {
|
|
2756
|
+
callback(null, [{ address: pinned.address, family: pinned.family }]);
|
|
2757
|
+
} else {
|
|
2758
|
+
callback(null, pinned.address, pinned.family);
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
return {
|
|
2762
|
+
protocol: parsed.protocol,
|
|
2763
|
+
agent: parsed.protocol === "https:" ? new https2.Agent({ lookup: lookup2 }) : new http2.Agent({ lookup: lookup2 })
|
|
2764
|
+
};
|
|
2765
|
+
};
|
|
2766
|
+
var safeRequest = async ({
|
|
2767
|
+
body,
|
|
2768
|
+
headers = {},
|
|
2769
|
+
method = "GET",
|
|
2770
|
+
query,
|
|
2771
|
+
timeout = DEFAULT_TIMEOUT_MS2,
|
|
2772
|
+
type = "json",
|
|
2773
|
+
url
|
|
2774
|
+
}) => {
|
|
2775
|
+
const full = new URL(url);
|
|
2776
|
+
if (query) {
|
|
2777
|
+
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
2778
|
+
}
|
|
2779
|
+
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
2780
|
+
const isForm = type === "form";
|
|
2781
|
+
try {
|
|
2782
|
+
const response = await axios({
|
|
2783
|
+
method,
|
|
2784
|
+
url: full.toString(),
|
|
2785
|
+
headers: {
|
|
2786
|
+
"Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
|
|
2787
|
+
...headers
|
|
2788
|
+
},
|
|
2789
|
+
...body !== void 0 && {
|
|
2790
|
+
data: isForm ? new URLSearchParams(body).toString() : body
|
|
2791
|
+
},
|
|
2792
|
+
timeout,
|
|
2793
|
+
maxRedirects: 0,
|
|
2794
|
+
httpAgent: protocol === "http:" ? agent : void 0,
|
|
2795
|
+
httpsAgent: protocol === "https:" ? agent : void 0,
|
|
2796
|
+
responseType: "json",
|
|
2797
|
+
validateStatus: (status) => status >= 200 && status < 300
|
|
2798
|
+
});
|
|
2799
|
+
return response.data || null;
|
|
2800
|
+
} catch (error) {
|
|
2801
|
+
if (error == null ? void 0 : error.response) {
|
|
2802
|
+
const normalized = new Error(
|
|
2803
|
+
typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
|
|
2804
|
+
);
|
|
2805
|
+
normalized.status = error.response.status;
|
|
2806
|
+
normalized.response = error.response.data;
|
|
2807
|
+
throw normalized;
|
|
2808
|
+
}
|
|
2809
|
+
throw error;
|
|
2810
|
+
}
|
|
2811
|
+
};
|
|
2812
|
+
|
|
2813
|
+
// lib/connections/webhook.js
|
|
2814
|
+
var webhook_default = {
|
|
2815
|
+
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
2816
|
+
// so the buttons say what actually happens.
|
|
2817
|
+
actions: {
|
|
2818
|
+
create: "Connect",
|
|
2819
|
+
update: "Regenerate secret"
|
|
2820
|
+
},
|
|
2821
|
+
// GENERATED. There is no third party and nothing to authenticate against --
|
|
2822
|
+
// connecting mints a secret rather than proving a credential.
|
|
2823
|
+
auth: {
|
|
2824
|
+
type: "generated"
|
|
2825
|
+
},
|
|
2826
|
+
// EVERYTHING A MERCHANT READS. `errors` would belong here too — the
|
|
2827
|
+
// connection DOCUMENT carries its own `errors` array and is spread OVER the
|
|
2828
|
+
// resolved manifest downstream, so a top-level one is replaced by that array.
|
|
2829
|
+
content: {
|
|
2830
|
+
confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
|
|
2831
|
+
description: [
|
|
2832
|
+
"Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
|
|
2833
|
+
"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."
|
|
2834
|
+
],
|
|
2835
|
+
excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
|
|
2836
|
+
guide: [
|
|
2837
|
+
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
2838
|
+
"Copy the secret into your own endpoint.",
|
|
2839
|
+
"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."
|
|
2840
|
+
]
|
|
2841
|
+
},
|
|
2842
|
+
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
2843
|
+
// merchant with two endpoints is a step-level choice rather than a second
|
|
2844
|
+
// connection.
|
|
2845
|
+
exclusive: false,
|
|
2846
|
+
feature: "organization:connection:webhook",
|
|
2847
|
+
fields: [
|
|
2848
|
+
{
|
|
2849
|
+
// No `input`: Drawbridge generates this, the merchant never types it.
|
|
2850
|
+
// No `redact` either — it is shared with the merchant's own endpoint
|
|
2851
|
+
// rather than being a third-party credential, so it round-trips for
|
|
2852
|
+
// them to copy and configure.
|
|
2853
|
+
copy: true,
|
|
2854
|
+
key: "secret",
|
|
2855
|
+
label: "Signing secret"
|
|
2856
|
+
}
|
|
2857
|
+
],
|
|
2858
|
+
group: "developer",
|
|
2859
|
+
// OUTBOUND ONLY. inbound.* is false because the direction is the point: we
|
|
2860
|
+
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
2861
|
+
// false follows from there being no third party to authenticate against —
|
|
2862
|
+
// connect generates a secret rather than proving a credential.
|
|
2863
|
+
hooks: {
|
|
2864
|
+
auth: {
|
|
2865
|
+
// FALSE, NOT {}. There is no vendor here at all — connecting mints a
|
|
2866
|
+
// secret and disconnecting clears it, both done by the api's own
|
|
2867
|
+
// handler. `{}` would promise a body implemented elsewhere, and none
|
|
2868
|
+
// exists or could.
|
|
2869
|
+
connect: false,
|
|
2870
|
+
disconnect: false,
|
|
2871
|
+
probe: false,
|
|
2872
|
+
scopes: false,
|
|
2873
|
+
// Nothing to mint. Connecting generates a secret; there is no vendor.
|
|
2874
|
+
token: false
|
|
2875
|
+
},
|
|
2876
|
+
commerce: false,
|
|
2877
|
+
contacts: { remove: false, sync: false },
|
|
2878
|
+
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2879
|
+
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2880
|
+
// these would be a second sender, which is the arrangement the platform
|
|
2881
|
+
// sender replaced.
|
|
2882
|
+
email: false,
|
|
2883
|
+
segment: false,
|
|
2884
|
+
sms: false,
|
|
2885
|
+
inbound: false,
|
|
2886
|
+
lifecycle: false,
|
|
2887
|
+
resources: {
|
|
2888
|
+
audiences: false,
|
|
2889
|
+
prices: false,
|
|
2890
|
+
products: false,
|
|
2891
|
+
promotions: false
|
|
2892
|
+
},
|
|
2893
|
+
// THE BODY IS HERE, not in drawbridge-sync. It needs `crypto` and an HTTP
|
|
2894
|
+
// client and nothing else — no controller, no queue, no database — so
|
|
2895
|
+
// there was never a reason for it to live in another repo.
|
|
2896
|
+
//
|
|
2897
|
+
// That is the rule the whole split runs on: a hook lives in sync only if it
|
|
2898
|
+
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2899
|
+
webhook: {
|
|
2900
|
+
send: async ({ context, controller, request: send2 = safeRequest, settings, step }) => {
|
|
2901
|
+
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
2902
|
+
const request2 = { method, url: url || null };
|
|
2903
|
+
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
2904
|
+
const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
|
|
2905
|
+
const body = lead || context;
|
|
2906
|
+
request2.body = body;
|
|
2907
|
+
const outgoing = { ...headers };
|
|
2908
|
+
if (settings == null ? void 0 : settings.secret) {
|
|
2909
|
+
outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto3.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
|
|
2910
|
+
}
|
|
2911
|
+
const response = await send2({ body, headers: outgoing, method, url });
|
|
2912
|
+
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
},
|
|
2916
|
+
// Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
|
|
2917
|
+
// It is the one card that reads wrong — our logo among vendor logos — and it
|
|
2918
|
+
// wants a mark of its own when there is one.
|
|
2919
|
+
icon: drawbridge_default,
|
|
2920
|
+
// Gated on the encryption secret: without it the signing secret could not be
|
|
2921
|
+
// stored safely, so the connection must not be offered at all.
|
|
2922
|
+
requires: ["ENCRYPT_CONNECTION_SECRET"],
|
|
2923
|
+
// Outbound only. inbound.* is false because the direction is the point: we
|
|
2924
|
+
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
2925
|
+
// false follows from there being no third party to authenticate against —
|
|
2926
|
+
// connect generates a secret rather than proving a credential.
|
|
2927
|
+
slug: "webhook",
|
|
2928
|
+
// The destination url is supplied per step, not per connection, so there is
|
|
2929
|
+
// nothing to finish here — generating the secret IS connecting, and no
|
|
2930
|
+
// setting can make this connection unusable. The credential's own verdict
|
|
2931
|
+
// stands.
|
|
2932
|
+
status: (data2) => data2 == null ? void 0 : data2.status,
|
|
2933
|
+
steps: {
|
|
2934
|
+
webhook: {
|
|
2935
|
+
send: () => ({
|
|
2936
|
+
hook: "webhook.send",
|
|
2937
|
+
key: "Send webhook",
|
|
2938
|
+
queue: "webhook",
|
|
2939
|
+
settings: {
|
|
2940
|
+
url: { format: "url", required: true, type: "string" }
|
|
2941
|
+
},
|
|
2942
|
+
triggers: ["lead.insert", "lead.delete"],
|
|
2943
|
+
// Replaces `billable : true`, which fed BILLABLE_STEP_TYPES, which set
|
|
2944
|
+
// workflow.billable at save, which sync then checked against a usage
|
|
2945
|
+
// the handler returned — three hops for one fact, two of which could
|
|
2946
|
+
// disagree silently.
|
|
2947
|
+
usage: { actions: 1 }
|
|
2948
|
+
})
|
|
2949
|
+
}
|
|
2950
|
+
},
|
|
2951
|
+
// The card the connection page raises. Before connecting it explains what
|
|
2952
|
+
// pressing Connect will do; afterwards it states the verification the
|
|
2953
|
+
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
2954
|
+
// checks is an unsigned payload.
|
|
2955
|
+
tasks: ({ settings }) => (settings == null ? void 0 : settings.secret) ? [
|
|
2956
|
+
{
|
|
2957
|
+
message: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
|
|
2958
|
+
title: "Requests must be verified",
|
|
2959
|
+
type: "warning"
|
|
2960
|
+
}
|
|
2961
|
+
] : [
|
|
2962
|
+
{
|
|
2963
|
+
message: "Connect to generate a signing secret. Drawbridge signs every webhook it sends with it.",
|
|
2964
|
+
title: "Webhook signing"
|
|
2965
|
+
}
|
|
2966
|
+
],
|
|
2967
|
+
title: "Webhooks"
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
// lib/connections/index.js
|
|
2971
|
+
var QUEUES = ["connection", "notification", "segment", "webhook"];
|
|
2972
|
+
var implemented = (hooks, path) => {
|
|
2973
|
+
const hook = path.split(".").reduce((node, key) => node == null ? void 0 : node[key], hooks);
|
|
2974
|
+
return typeof hook === "function" || !!hook && typeof hook === "object";
|
|
2975
|
+
};
|
|
2976
|
+
var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
|
|
2977
|
+
([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
|
|
2978
|
+
);
|
|
2979
|
+
var build = (manifest) => {
|
|
2980
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q;
|
|
2981
|
+
if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
|
|
2982
|
+
if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
|
|
2983
|
+
if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
|
|
2984
|
+
if (!((_a = manifest == null ? void 0 : manifest.content) == null ? void 0 : _a.excerpt)) throw new Error(manifest.slug + " needs content.excerpt for its card");
|
|
2985
|
+
if (!Array.isArray((_b = manifest == null ? void 0 : manifest.content) == null ? void 0 : _b.description) || !manifest.content.description.length) {
|
|
2986
|
+
throw new Error(manifest.slug + " needs content.description \u2014 an array of paragraphs for its page");
|
|
2987
|
+
}
|
|
2988
|
+
for (const field2 of manifest.fields || []) {
|
|
2989
|
+
if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
|
|
2990
|
+
throw new Error(manifest.slug + " declares a field with no key or label");
|
|
2991
|
+
}
|
|
2992
|
+
if (field2.input && !INPUTS.includes(field2.input)) {
|
|
2993
|
+
throw new Error(manifest.slug + "." + field2.key + " declares an unknown input: " + field2.input + " \u2014 one of " + INPUTS.join(", "));
|
|
2994
|
+
}
|
|
2995
|
+
if (field2.input === "select" && !(field2.options || []).length && !field2.hook) {
|
|
2996
|
+
throw new Error(manifest.slug + "." + field2.key + " is a select and must declare options or a hook");
|
|
2997
|
+
}
|
|
2998
|
+
if (field2.hook) {
|
|
2999
|
+
if (!HOOK_NAMES.includes(field2.hook)) {
|
|
3000
|
+
throw new Error(manifest.slug + "." + field2.key + " names an unknown hook: " + field2.hook);
|
|
3001
|
+
}
|
|
3002
|
+
if (!field2.hook.startsWith("resources.")) {
|
|
3003
|
+
throw new Error(manifest.slug + "." + field2.key + " reads from " + field2.hook + " \u2014 a picker may only read resources.*");
|
|
3004
|
+
}
|
|
3005
|
+
if (!implemented(manifest.hooks, field2.hook)) {
|
|
3006
|
+
throw new Error(manifest.slug + "." + field2.key + " reads " + field2.hook + ", which this vendor does not implement");
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
|
|
3011
|
+
throw new Error(manifest.slug + " needs an icon \u2014 the svg markup itself, not a path to one");
|
|
3012
|
+
}
|
|
3013
|
+
if (!manifest.icon.includes("viewBox")) {
|
|
3014
|
+
throw new Error(manifest.slug + " icon has no viewBox, so it cannot scale");
|
|
3015
|
+
}
|
|
3016
|
+
if (manifest.icon.includes("<image")) {
|
|
3017
|
+
throw new Error(manifest.slug + " icon wraps a raster \u2014 re-export it as vector shapes");
|
|
3018
|
+
}
|
|
3019
|
+
if (!GROUPS.includes(manifest == null ? void 0 : manifest.group)) {
|
|
3020
|
+
throw new Error(manifest.slug + " needs a group \u2014 one of " + GROUPS.join(", "));
|
|
3021
|
+
}
|
|
3022
|
+
if ((_c = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _c.type) {
|
|
3023
|
+
throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
|
|
3024
|
+
}
|
|
3025
|
+
if (!AUTH_TYPES.includes((_d = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _d.type)) {
|
|
3026
|
+
throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
|
|
3027
|
+
}
|
|
3028
|
+
if (manifest.auth.type === "oauth") {
|
|
3029
|
+
for (const field2 of OAUTH_FIELDS) {
|
|
3030
|
+
if (!((_e = manifest.auth.oauth) == null ? void 0 : _e[field2])) {
|
|
3031
|
+
throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field2);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
if (typeof ((_g = (_f = manifest.hooks) == null ? void 0 : _f.auth) == null ? void 0 : _g.token) !== "function") {
|
|
3035
|
+
throw new Error(manifest.slug + " is oauth and must implement hooks.auth.token \u2014 point it at authToken() or wrap it");
|
|
3036
|
+
}
|
|
3037
|
+
for (const url of OAUTH_URLS) {
|
|
3038
|
+
if (!((_i = (_h = manifest.auth.oauth) == null ? void 0 : _h.urls) == null ? void 0 : _i[url])) {
|
|
3039
|
+
throw new Error(manifest.slug + " is oauth and must declare auth.oauth.urls." + url);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
if (manifest.auth.oauth.urls.redirect !== "/api/connection/" + manifest.slug + "/callback") {
|
|
3043
|
+
throw new Error(
|
|
3044
|
+
manifest.slug + " declares auth.oauth.urls.redirect " + manifest.auth.oauth.urls.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (implemented(manifest.hooks, "inbound.event") && !((_k = (_j = manifest.inbound) == null ? void 0 : _j.headers) == null ? void 0 : _k.event)) {
|
|
3049
|
+
throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
|
|
3050
|
+
}
|
|
3051
|
+
if (implemented(manifest.hooks, "inbound.verify") && !((_m = (_l = manifest.inbound) == null ? void 0 : _l.headers) == null ? void 0 : _m.signature)) {
|
|
3052
|
+
throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
|
|
3053
|
+
}
|
|
3054
|
+
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
3055
|
+
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");
|
|
3056
|
+
}
|
|
3057
|
+
if (!Array.isArray((_n = manifest == null ? void 0 : manifest.content) == null ? void 0 : _n.guide) || !manifest.content.guide.length) {
|
|
3058
|
+
throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
|
|
3059
|
+
}
|
|
3060
|
+
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
3061
|
+
throw new Error(manifest.slug + " must declare status( data ) \u2014 one of " + STATUSES.join(", "));
|
|
3062
|
+
}
|
|
3063
|
+
const status = manifest.status({});
|
|
3064
|
+
if (status != null && !STATUSES.includes(status)) {
|
|
3065
|
+
throw new Error(manifest.slug + " status() returned " + status + " \u2014 must be one of " + STATUSES.join(", "));
|
|
3066
|
+
}
|
|
3067
|
+
for (const [domain, verbs] of Object.entries(HOOKS)) {
|
|
3068
|
+
for (const verb of verbs) {
|
|
3069
|
+
const hook = (_p = (_o = manifest.hooks) == null ? void 0 : _o[domain]) == null ? void 0 : _p[verb];
|
|
3070
|
+
if (((_q = manifest.hooks) == null ? void 0 : _q[domain]) === false) continue;
|
|
3071
|
+
if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
|
|
3072
|
+
throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
for (const [domain, verbs] of Object.entries(manifest.hooks || {})) {
|
|
3077
|
+
if (!HOOKS[domain]) throw new Error(manifest.slug + " implements an unknown hook domain: " + domain);
|
|
3078
|
+
for (const verb of Object.keys(verbs === false ? {} : verbs)) {
|
|
3079
|
+
if (!HOOKS[domain].includes(verb)) {
|
|
3080
|
+
throw new Error(manifest.slug + " implements an unknown hook: " + domain + "." + verb);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
for (const [name, step] of leaves(manifest.steps)) {
|
|
3085
|
+
const type = "step." + name;
|
|
3086
|
+
if (!STEPS[name]) {
|
|
3087
|
+
throw new Error(manifest.slug + " declares an unknown step: " + type + " \u2014 add it to STEPS in contract.js");
|
|
3088
|
+
}
|
|
3089
|
+
const declared = step({});
|
|
3090
|
+
if (!(declared == null ? void 0 : declared.key)) throw new Error(manifest.slug + " step " + type + " needs a key \u2014 the label the builder shows");
|
|
3091
|
+
if (!QUEUES.includes(declared == null ? void 0 : declared.queue)) {
|
|
3092
|
+
throw new Error(manifest.slug + " step " + type + " needs a queue \u2014 one of " + QUEUES.join(", "));
|
|
3093
|
+
}
|
|
3094
|
+
if (declared.hook && !implemented(manifest.hooks, declared.hook)) {
|
|
3095
|
+
throw new Error(manifest.slug + " step " + type + " points at hook " + declared.hook + ", which this vendor does not implement");
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
return Object.freeze({
|
|
3099
|
+
...manifest,
|
|
3100
|
+
fields: Object.freeze(manifest.fields || []),
|
|
3101
|
+
hooks: Object.freeze(manifest.hooks || {}),
|
|
3102
|
+
inbound: Object.freeze(manifest.inbound || {}),
|
|
3103
|
+
requires: Object.freeze(manifest.requires || []),
|
|
3104
|
+
steps: Object.freeze(manifest.steps || {})
|
|
3105
|
+
});
|
|
3106
|
+
};
|
|
3107
|
+
var connections = Object.freeze({
|
|
3108
|
+
attentive: build(attentive_default2),
|
|
3109
|
+
drawbridge: build(drawbridge_default2),
|
|
3110
|
+
klaviyo: build(klaviyo_default2),
|
|
3111
|
+
mailchimp: build(mailchimp_default2),
|
|
3112
|
+
shopify: build(shopify_default2),
|
|
3113
|
+
webhook: build(webhook_default)
|
|
3114
|
+
});
|
|
3115
|
+
(() => {
|
|
3116
|
+
const owners = {};
|
|
3117
|
+
for (const [slug, manifest] of Object.entries(connections)) {
|
|
3118
|
+
for (const [name] of leaves(manifest.steps)) {
|
|
3119
|
+
const type = "step." + name;
|
|
3120
|
+
if (owners[type]) {
|
|
3121
|
+
throw new Error("Step " + type + " is declared by both " + owners[type] + " and " + slug);
|
|
3122
|
+
}
|
|
3123
|
+
owners[type] = slug;
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
})();
|
|
3127
|
+
var publicSettingsBySlug = Object.fromEntries(
|
|
3128
|
+
Object.entries(connections).map(([slug, manifest]) => [
|
|
3129
|
+
slug,
|
|
3130
|
+
manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
|
|
3131
|
+
])
|
|
3132
|
+
);
|
|
3133
|
+
var mergeSettings = ({ existing, incoming }) => {
|
|
3134
|
+
if (!existing || typeof existing !== "object") return incoming;
|
|
3135
|
+
const merged = { ...existing };
|
|
3136
|
+
for (const [key, value] of Object.entries(incoming || {})) {
|
|
3137
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
3138
|
+
merged[key] = value;
|
|
3139
|
+
}
|
|
3140
|
+
;
|
|
3141
|
+
}
|
|
3142
|
+
;
|
|
3143
|
+
return merged;
|
|
3144
|
+
};
|
|
3145
|
+
var publicConnectionKeys = Object.freeze([
|
|
3146
|
+
"actions",
|
|
3147
|
+
// API-COMPOSED, not manifest-declared: the api's resolve() builds it from
|
|
3148
|
+
// auth.type, content.redirect and the manifest's manage() — the client reads
|
|
3149
|
+
// connect.type to choose entered-vs-installed, connect.redirect for the App
|
|
3150
|
+
// Store link, connect.manage for the admin deep link. It was dropped from
|
|
3151
|
+
// this list when the manifests stopped declaring it, which stripped the
|
|
3152
|
+
// composed object from every response and broke all three.
|
|
3153
|
+
"connect",
|
|
3154
|
+
// EVERYTHING A MERCHANT READS, in one key: confirm, description, errors,
|
|
3155
|
+
// excerpt, guide, and any vendor redirect copy.
|
|
3156
|
+
"content",
|
|
3157
|
+
"createdAt",
|
|
3158
|
+
// The connection DOCUMENT's own errors array — scope-drift entries written by
|
|
3159
|
+
// drawbridge-sync. NOT the manifest's error copy, which is content.errors:
|
|
3160
|
+
// the document is spread OVER the resolved manifest downstream, so the two
|
|
3161
|
+
// sharing this key means the array silently wins.
|
|
3162
|
+
"errors",
|
|
3163
|
+
"fields",
|
|
3164
|
+
"group",
|
|
3165
|
+
"id",
|
|
3166
|
+
"image",
|
|
3167
|
+
"settings",
|
|
3168
|
+
"shop",
|
|
3169
|
+
"slug",
|
|
3170
|
+
"status",
|
|
3171
|
+
"tasks",
|
|
3172
|
+
"title",
|
|
3173
|
+
"updatedAt",
|
|
3174
|
+
"warnings"
|
|
3175
|
+
]);
|
|
3176
|
+
|
|
3177
|
+
// lib/encrypt.js
|
|
3178
|
+
var import_crypto2 = __toESM(require("crypto"), 1);
|
|
3179
|
+
|
|
3180
|
+
// lib/token.js
|
|
3181
|
+
var import_crypto = __toESM(require("crypto"), 1);
|
|
3182
|
+
var generate = (bytes = 32, encoding = "base64url") => {
|
|
3183
|
+
const buf = import_crypto.default.randomBytes(bytes);
|
|
3184
|
+
return encoding ? buf.toString(encoding) : buf;
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
// lib/encrypt.js
|
|
3188
|
+
var ALGORITHM = "aes-256-gcm";
|
|
3189
|
+
var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
|
|
3190
|
+
var encrypt = (value) => {
|
|
3191
|
+
const iv = generate(12, null);
|
|
3192
|
+
const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
|
|
3193
|
+
const data2 = Buffer.concat([
|
|
3194
|
+
cipher.update(JSON.stringify(value), "utf8"),
|
|
3195
|
+
cipher.final()
|
|
3196
|
+
]);
|
|
3197
|
+
const tag = cipher.getAuthTag();
|
|
3198
|
+
return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
|
|
3199
|
+
};
|
|
3200
|
+
var decrypt = (value) => {
|
|
3201
|
+
if (typeof value !== "string") return value;
|
|
3202
|
+
const [ivHex, tagHex, dataHex] = value.split(":");
|
|
3203
|
+
const decipher = import_crypto2.default.createDecipheriv(
|
|
3204
|
+
ALGORITHM,
|
|
3205
|
+
getKey(),
|
|
3206
|
+
Buffer.from(ivHex, "hex")
|
|
3207
|
+
);
|
|
3208
|
+
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
|
3209
|
+
const result = Buffer.concat([
|
|
3210
|
+
decipher.update(Buffer.from(dataHex, "hex")),
|
|
3211
|
+
decipher.final()
|
|
3212
|
+
]);
|
|
3213
|
+
return JSON.parse(result.toString("utf8"));
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
// lib/providers.js
|
|
3217
|
+
var FIELDS = {
|
|
3218
|
+
attentive: [
|
|
3219
|
+
{ input: "text", key: "clientId", env: "ATTENTIVE_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
3220
|
+
{ input: "password", key: "clientSecret", env: "ATTENTIVE_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
3221
|
+
],
|
|
3222
|
+
drawbridge: [
|
|
3223
|
+
{ input: "email", key: "accountSender", env: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
|
|
3224
|
+
{ input: "password", key: "apiKey", env: "SENDGRID_API_KEY", label: "SendGrid API key", redact: true, required: true },
|
|
3225
|
+
// NOT required. The CRM sync is best-effort internal tooling and no-ops
|
|
3226
|
+
// without a token — requiring it would make the whole drawbridge provider
|
|
3227
|
+
// read not-live over something no merchant ever sees.
|
|
3228
|
+
{ input: "password", key: "hubspotToken", env: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true },
|
|
3229
|
+
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
3230
|
+
// either. Unset, it degrades to the account sender rather than
|
|
3231
|
+
// refusing to start.
|
|
3232
|
+
{ input: "email", key: "leadSender", env: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." },
|
|
3233
|
+
{ input: "text", key: "smsFrom", env: "TWILIO_ACCOUNT_FROM", label: "SMS number", required: true },
|
|
3234
|
+
{ input: "password", key: "smsSid", env: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", redact: true, required: true },
|
|
3235
|
+
{ input: "password", key: "smsToken", env: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", redact: true, required: true }
|
|
3236
|
+
],
|
|
3237
|
+
klaviyo: [
|
|
3238
|
+
{ input: "text", key: "clientId", env: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
3239
|
+
{ input: "password", key: "clientSecret", env: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
3240
|
+
],
|
|
3241
|
+
mailchimp: [
|
|
3242
|
+
{ input: "text", key: "clientId", env: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
3243
|
+
{ input: "password", key: "clientSecret", env: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
3244
|
+
],
|
|
3245
|
+
shopify: [
|
|
3246
|
+
{ input: "text", key: "apiKey", env: "SHOPIFY_API_KEY", label: "API key", required: true },
|
|
3247
|
+
{ input: "password", key: "apiSecret", env: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
|
|
3248
|
+
{ input: "text", key: "appHandle", env: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
|
|
3249
|
+
{ input: "text", key: "listingUrl", env: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
|
|
3250
|
+
]
|
|
3251
|
+
};
|
|
3252
|
+
var providerFields = (slug) => Object.hasOwn(FIELDS, slug) ? FIELDS[slug] : [];
|
|
3253
|
+
var providerSlugs = () => Object.keys(FIELDS).sort();
|
|
3254
|
+
var isLive = (slug, settings) => {
|
|
3255
|
+
const fields2 = providerFields(slug);
|
|
3256
|
+
if (!fields2.length) return false;
|
|
3257
|
+
return fields2.filter((field2) => field2.required).every((field2) => Boolean(settings == null ? void 0 : settings[field2.key]));
|
|
3258
|
+
};
|
|
3259
|
+
var mask = (value) => {
|
|
3260
|
+
if (!value) return null;
|
|
3261
|
+
if (String(value).length < 12) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
3262
|
+
return String(value).slice(0, 3) + "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" + String(value).slice(-4);
|
|
3263
|
+
};
|
|
3264
|
+
var cacheKey = (slug) => ["provider", slug];
|
|
3265
|
+
var providerSettings = async ({ cache, controller, slug }) => {
|
|
3266
|
+
const read = async () => controller.get({
|
|
3267
|
+
collection: "provider",
|
|
3268
|
+
query: { slug }
|
|
3269
|
+
});
|
|
3270
|
+
const row = cache ? await cache.use(cacheKey(slug), read, 30) : await read();
|
|
3271
|
+
return (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
|
|
3272
|
+
};
|
|
3273
|
+
var saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
|
|
3274
|
+
const fields2 = providerFields(slug);
|
|
3275
|
+
if (!fields2.length) return null;
|
|
3276
|
+
const existing = await controller.get({
|
|
3277
|
+
collection: "provider",
|
|
3278
|
+
query: { slug }
|
|
3279
|
+
});
|
|
3280
|
+
const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
|
|
3281
|
+
const merged = mergeSettings({
|
|
3282
|
+
existing: stored,
|
|
3283
|
+
incoming: Object.fromEntries(fields2.map((field2) => [field2.key, settings == null ? void 0 : settings[field2.key]]))
|
|
3284
|
+
});
|
|
3285
|
+
for (const key of Array.isArray(clear) ? clear : []) {
|
|
3286
|
+
if (fields2.some((field2) => field2.key === key)) delete merged[key];
|
|
3287
|
+
}
|
|
3288
|
+
const result = await controller.update({
|
|
3289
|
+
authenticated,
|
|
3290
|
+
collection: "provider",
|
|
3291
|
+
data: {
|
|
3292
|
+
$set: {
|
|
3293
|
+
settings: encrypt(merged)
|
|
3294
|
+
}
|
|
3295
|
+
},
|
|
3296
|
+
options: {
|
|
3297
|
+
upsert: true
|
|
3298
|
+
},
|
|
3299
|
+
query: { slug }
|
|
3300
|
+
});
|
|
3301
|
+
await cache.delete(cacheKey(slug));
|
|
3302
|
+
return result;
|
|
3303
|
+
};
|
|
3304
|
+
var providerEnvNames = () => new Set(
|
|
3305
|
+
providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.env).filter(Boolean)
|
|
3306
|
+
);
|
|
3307
|
+
var providerCredentials = async ({ cache, controller }) => {
|
|
3308
|
+
const credentials2 = {};
|
|
3309
|
+
for (const slug of providerSlugs()) {
|
|
3310
|
+
const settings = await providerSettings({ cache, controller, slug });
|
|
3311
|
+
for (const field2 of providerFields(slug)) {
|
|
3312
|
+
const value = settings == null ? void 0 : settings[field2.key];
|
|
3313
|
+
if (field2.env && value) credentials2[field2.env] = value;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return credentials2;
|
|
3317
|
+
};
|
|
3318
|
+
var hydrateEnvironment = async ({ cache, controller, env = process.env }) => {
|
|
3319
|
+
const credentials2 = await providerCredentials({ cache, controller });
|
|
3320
|
+
for (const name of providerEnvNames()) delete env[name];
|
|
3321
|
+
Object.assign(env, credentials2);
|
|
3322
|
+
return Object.keys(credentials2).sort();
|
|
3323
|
+
};
|
|
3324
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3325
|
+
0 && (module.exports = {
|
|
3326
|
+
cacheKey,
|
|
3327
|
+
hydrateEnvironment,
|
|
3328
|
+
isLive,
|
|
3329
|
+
mask,
|
|
3330
|
+
providerCredentials,
|
|
3331
|
+
providerEnvNames,
|
|
3332
|
+
providerFields,
|
|
3333
|
+
providerSettings,
|
|
3334
|
+
providerSlugs,
|
|
3335
|
+
saveProviderSettings
|
|
3336
|
+
});
|