@drawbridge/drawbridge-utils 0.0.108 → 0.0.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +196 -6
- package/dist/connections/index.d.cts +309 -7
- package/dist/connections/index.d.ts +309 -7
- package/dist/connections/index.js +192 -5
- package/package.json +1 -1
|
@@ -200,6 +200,56 @@ var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refr
|
|
|
200
200
|
};
|
|
201
201
|
};
|
|
202
202
|
|
|
203
|
+
// lib/connections/token.js
|
|
204
|
+
var SKEW_SECONDS = 120;
|
|
205
|
+
var isStale = (settings, now = Date.now()) => {
|
|
206
|
+
if (!(settings == null ? void 0 : settings.expiresAt)) return false;
|
|
207
|
+
return new Date(settings.expiresAt).getTime() - SKEW_SECONDS * 1e3 <= now;
|
|
208
|
+
};
|
|
209
|
+
var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
|
|
210
|
+
accessToken: tokens.accessToken,
|
|
211
|
+
// A vendor that does not rotate its refresh token returns none on a refresh
|
|
212
|
+
// (Klaviyo). Keeping the existing one is what makes the NEXT refresh work —
|
|
213
|
+
// dropping it invalidates the grant one call later, nowhere near the cause.
|
|
214
|
+
...(tokens.refreshToken || existing.refreshToken) && {
|
|
215
|
+
refreshToken: tokens.refreshToken || existing.refreshToken
|
|
216
|
+
},
|
|
217
|
+
// Absent when the vendor issues non-expiring tokens, and absent is meaningful
|
|
218
|
+
// — isStale reads it as "nothing to refresh toward".
|
|
219
|
+
...tokens.expiresIn && {
|
|
220
|
+
expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
|
|
221
|
+
},
|
|
222
|
+
...tokens.scope && { scope: tokens.scope }
|
|
223
|
+
});
|
|
224
|
+
var accessToken = async ({
|
|
225
|
+
clientId,
|
|
226
|
+
clientSecret,
|
|
227
|
+
fetcher,
|
|
228
|
+
force = false,
|
|
229
|
+
manifest,
|
|
230
|
+
now = Date.now(),
|
|
231
|
+
save,
|
|
232
|
+
settings
|
|
233
|
+
} = {}) => {
|
|
234
|
+
if (!(settings == null ? void 0 : settings.accessToken) && !(settings == null ? void 0 : settings.refreshToken)) {
|
|
235
|
+
throw new Error("This connection holds no credential, so there is no token to use");
|
|
236
|
+
}
|
|
237
|
+
if (!force && !isStale(settings, now)) return settings.accessToken;
|
|
238
|
+
if (!settings.refreshToken) {
|
|
239
|
+
throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
|
|
240
|
+
}
|
|
241
|
+
const minted = await refresh({
|
|
242
|
+
clientId,
|
|
243
|
+
clientSecret,
|
|
244
|
+
descriptor: manifest.auth.oauth,
|
|
245
|
+
...fetcher && { fetcher },
|
|
246
|
+
refreshToken: settings.refreshToken
|
|
247
|
+
});
|
|
248
|
+
const next = tokenSettings({ existing: settings, now, tokens: minted });
|
|
249
|
+
if (save) await save(next);
|
|
250
|
+
return next.accessToken;
|
|
251
|
+
};
|
|
252
|
+
|
|
203
253
|
// lib/connections/icons/klaviyo.js
|
|
204
254
|
var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
205
255
|
<rect width="500" height="500" fill="white"/>
|
|
@@ -207,6 +257,26 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
207
257
|
</svg>`;
|
|
208
258
|
|
|
209
259
|
// lib/connections/klaviyo.js
|
|
260
|
+
var REVISION = "2026-07-15";
|
|
261
|
+
var api = async (path, { fetcher = fetch, token }) => {
|
|
262
|
+
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
263
|
+
headers: {
|
|
264
|
+
// Bearer, not Klaviyo-API-Key — that header is for private keys, and
|
|
265
|
+
// sending it with an OAuth token fails in a way that reads like a bad
|
|
266
|
+
// token rather than a bad scheme.
|
|
267
|
+
authorization: "Bearer " + token,
|
|
268
|
+
revision: REVISION
|
|
269
|
+
},
|
|
270
|
+
signal: AbortSignal.timeout(15e3)
|
|
271
|
+
});
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
throw Object.assign(
|
|
274
|
+
new Error("Klaviyo refused the request (" + response.status + ")"),
|
|
275
|
+
{ status: response.status }
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return response.json();
|
|
279
|
+
};
|
|
210
280
|
var klaviyo_default2 = {
|
|
211
281
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
212
282
|
// exchange without a code_verifier matching the challenge the consent
|
|
@@ -235,6 +305,11 @@ var klaviyo_default2 = {
|
|
|
235
305
|
// it is a fact about someone else's records rather than a string this
|
|
236
306
|
// code computes. Deriving one from a provider key produced
|
|
237
307
|
// redirect_uri_mismatch on a connection nobody had touched.
|
|
308
|
+
// Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
|
|
309
|
+
// never mentions this at runtime — you discover it when a refresh fails
|
|
310
|
+
// on a connection nobody touched — so it is declared, and it is why
|
|
311
|
+
// auth.probe has to run on a schedule rather than only before a call.
|
|
312
|
+
idleExpiry: 90 * 24 * 60 * 60,
|
|
238
313
|
redirect: "/api/connection/klaviyo/callback",
|
|
239
314
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
240
315
|
// and must stay in the list; the rest are what a contact sync needs.
|
|
@@ -266,7 +341,78 @@ var klaviyo_default2 = {
|
|
|
266
341
|
label: "Klaviyo account"
|
|
267
342
|
}
|
|
268
343
|
],
|
|
344
|
+
// The three auth hooks, all pure HTTP against Klaviyo — which is why they
|
|
345
|
+
// live here rather than in sync. A vendor's own protocol belongs beside the
|
|
346
|
+
// vendor.
|
|
347
|
+
hooks: {
|
|
348
|
+
// Turn a fresh grant into settings worth showing. Without this the card
|
|
349
|
+
// renders an empty "Klaviyo account" field, because the merchant is never
|
|
350
|
+
// asked which account they connected — the consent already decided it and
|
|
351
|
+
// asking again would be a question we can answer ourselves.
|
|
352
|
+
"auth.connect": async ({ fetcher, tokens }) => {
|
|
353
|
+
var _a, _b, _c;
|
|
354
|
+
const body = await api("/accounts", { fetcher, token: tokens.accessToken });
|
|
355
|
+
const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
|
|
356
|
+
return {
|
|
357
|
+
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,
|
|
358
|
+
accountId: (account == null ? void 0 : account.id) || null
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
// Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
|
|
362
|
+
// grant live in the merchant's account, so a disconnect that looks
|
|
363
|
+
// complete here still shows Drawbridge with access over there.
|
|
364
|
+
//
|
|
365
|
+
// Basic auth with our client, exactly like the token exchange — the token
|
|
366
|
+
// being revoked is the subject, not the credential.
|
|
367
|
+
"auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
|
|
368
|
+
const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
|
|
369
|
+
if (!token) return { revoked: false };
|
|
370
|
+
const response = await fetcher("https://a.klaviyo.com/oauth/revoke", {
|
|
371
|
+
body: new URLSearchParams({
|
|
372
|
+
token,
|
|
373
|
+
token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
|
|
374
|
+
}),
|
|
375
|
+
headers: {
|
|
376
|
+
authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
|
|
377
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
378
|
+
},
|
|
379
|
+
method: "POST",
|
|
380
|
+
signal: AbortSignal.timeout(15e3)
|
|
381
|
+
});
|
|
382
|
+
return { revoked: response.ok };
|
|
383
|
+
},
|
|
384
|
+
// THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
|
|
385
|
+
// what we stored answers the wrong question — a grant revoked inside
|
|
386
|
+
// Klaviyo still looks perfect in our database. Spending the refresh token
|
|
387
|
+
// is the only thing that asks Klaviyo.
|
|
388
|
+
//
|
|
389
|
+
// It also keeps the grant warm: Klaviyo expires a refresh token after 90
|
|
390
|
+
// days of NON-USE, so a connection nobody touches dies silently without
|
|
391
|
+
// this running.
|
|
392
|
+
"auth.probe": async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
|
|
393
|
+
const token = await accessToken({
|
|
394
|
+
clientId,
|
|
395
|
+
clientSecret,
|
|
396
|
+
fetcher,
|
|
397
|
+
// Mint even if the stored token still looks good — a probe that
|
|
398
|
+
// short-circuits never reaches Klaviyo and reports healthy on a
|
|
399
|
+
// grant revoked an hour ago.
|
|
400
|
+
force: true,
|
|
401
|
+
manifest,
|
|
402
|
+
settings
|
|
403
|
+
});
|
|
404
|
+
return { ok: Boolean(token) };
|
|
405
|
+
}
|
|
406
|
+
},
|
|
269
407
|
icon: klaviyo_default,
|
|
408
|
+
// A grant with no list chosen is authenticated and useless. The list cannot
|
|
409
|
+
// be part of the consent flow — enumerating lists needs the token the consent
|
|
410
|
+
// returns — so it is always a second step, and the card must say so rather
|
|
411
|
+
// than showing Active over nothing.
|
|
412
|
+
incomplete: (data) => {
|
|
413
|
+
var _a;
|
|
414
|
+
return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.list) ? null : "Choose which Klaviyo list your contacts should sync into.";
|
|
415
|
+
},
|
|
270
416
|
label: "klaviyo",
|
|
271
417
|
requires: [
|
|
272
418
|
"KLAVIYO_OAUTH_CLIENT_ID",
|
|
@@ -302,6 +448,16 @@ var klaviyo_default2 = {
|
|
|
302
448
|
"lifecycle.register": false,
|
|
303
449
|
"lifecycle.rehydrate": false
|
|
304
450
|
},
|
|
451
|
+
tasks: () => [
|
|
452
|
+
// Mailchimp carries the same warning, deliberately worded the same way. A
|
|
453
|
+
// merchant who connects either one and is told nothing reasonably assumes
|
|
454
|
+
// contacts are flowing, and finds out weeks later that they are not.
|
|
455
|
+
{
|
|
456
|
+
message: "Contact syncing to Klaviyo lists has not shipped yet. Connecting stores your authorization so it is ready, but nothing is being sent to Klaviyo right now.",
|
|
457
|
+
title: "List sync not available yet",
|
|
458
|
+
type: "warning"
|
|
459
|
+
}
|
|
460
|
+
],
|
|
305
461
|
title: "Klaviyo"
|
|
306
462
|
};
|
|
307
463
|
|
|
@@ -353,6 +509,15 @@ var mailchimp_default2 = {
|
|
|
353
509
|
}
|
|
354
510
|
],
|
|
355
511
|
icon: mailchimp_default,
|
|
512
|
+
// A key with no audience chosen is authenticated and inert. Mailchimp also
|
|
513
|
+
// needs its merge fields created on that audience before any Drawbridge total
|
|
514
|
+
// can be written to a member — unlike Klaviyo, its custom fields are not
|
|
515
|
+
// schemaless — so the audience must be picked before lifecycle.register has
|
|
516
|
+
// anything to register against.
|
|
517
|
+
incomplete: (data) => {
|
|
518
|
+
var _a;
|
|
519
|
+
return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.audience) ? null : "Choose which Mailchimp audience your contacts should sync into.";
|
|
520
|
+
},
|
|
356
521
|
label: "mailchimp",
|
|
357
522
|
// Uniform surface, honest answers. A key is stored and can be removed; nothing
|
|
358
523
|
// else is built yet, because audience sync has not shipped. Every false here
|
|
@@ -482,16 +647,21 @@ var shopify_default2 = {
|
|
|
482
647
|
}
|
|
483
648
|
],
|
|
484
649
|
group: "ecommerce",
|
|
650
|
+
// The install is the whole configuration — Shopify hands back the shop and
|
|
651
|
+
// there is nothing further to choose. `shop` absent means the install did not
|
|
652
|
+
// finish, which is a credential problem rather than a setup one, so the
|
|
653
|
+
// stored status already says so.
|
|
654
|
+
incomplete: () => null,
|
|
485
655
|
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
486
656
|
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
487
657
|
// write for either. receive is the one hook that genuinely differs by
|
|
488
|
-
//
|
|
658
|
+
// channel: /events buffers whatever arrives with the shop domain stamped on;
|
|
489
659
|
// /compliance enforces the topic allowlist above, because answering one late
|
|
490
660
|
// is a legal deadline rather than a retry.
|
|
491
661
|
hooks: {
|
|
492
662
|
"inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
493
|
-
"inbound.receive": ({
|
|
494
|
-
if (
|
|
663
|
+
"inbound.receive": ({ channel, event, headers, payload }) => {
|
|
664
|
+
if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
495
665
|
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
496
666
|
}
|
|
497
667
|
return {
|
|
@@ -499,7 +669,7 @@ var shopify_default2 = {
|
|
|
499
669
|
// own GDPR shape. The app-level event stream does not; that domain
|
|
500
670
|
// lives only in the header, so it is stamped on here rather than left
|
|
501
671
|
// for drawbridge-sync to reach into headers nobody hands it.
|
|
502
|
-
data:
|
|
672
|
+
data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
|
|
503
673
|
provider: { id: headers[inbound.headers.id] || null }
|
|
504
674
|
};
|
|
505
675
|
},
|
|
@@ -681,6 +851,9 @@ var webhook_default = {
|
|
|
681
851
|
// It is the one card that reads wrong — our logo among vendor logos — and it
|
|
682
852
|
// wants a mark of its own when there is one.
|
|
683
853
|
icon: drawbridge_default,
|
|
854
|
+
// The destination url is supplied per step, not per connection, so there is
|
|
855
|
+
// nothing to finish here — generating the secret IS connecting.
|
|
856
|
+
incomplete: () => null,
|
|
684
857
|
label: "webhook",
|
|
685
858
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
686
859
|
// stored safely, so the connection must not be offered at all.
|
|
@@ -788,6 +961,11 @@ var build = (manifest) => {
|
|
|
788
961
|
throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
|
|
789
962
|
}
|
|
790
963
|
}
|
|
964
|
+
if (manifest.auth.oauth.redirect !== "/api/connection/" + manifest.slug + "/callback") {
|
|
965
|
+
throw new Error(
|
|
966
|
+
manifest.slug + " declares auth.oauth.redirect " + manifest.auth.oauth.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
|
|
967
|
+
);
|
|
968
|
+
}
|
|
791
969
|
}
|
|
792
970
|
if (((_d = manifest.supports) == null ? void 0 : _d["inbound.event"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.event)) {
|
|
793
971
|
throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
|
|
@@ -795,6 +973,9 @@ var build = (manifest) => {
|
|
|
795
973
|
if (((_g = manifest.supports) == null ? void 0 : _g["inbound.verify"]) && !((_i = (_h = manifest.inbound) == null ? void 0 : _h.headers) == null ? void 0 : _i.signature)) {
|
|
796
974
|
throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
|
|
797
975
|
}
|
|
976
|
+
if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
|
|
977
|
+
throw new Error(manifest.slug + " must declare incomplete( data ) \u2014 return null when the connection is usable, or the reason it is not");
|
|
978
|
+
}
|
|
798
979
|
if (!Array.isArray(manifest == null ? void 0 : manifest.setup) || !manifest.setup.length) {
|
|
799
980
|
throw new Error(manifest.slug + " needs a setup guide \u2014 an array of steps for its page");
|
|
800
981
|
}
|
|
@@ -944,6 +1125,9 @@ var publicConnectionKeys = Object.freeze([
|
|
|
944
1125
|
"group",
|
|
945
1126
|
"id",
|
|
946
1127
|
"image",
|
|
1128
|
+
// The reason a connected vendor still is not usable — a Klaviyo grant with no
|
|
1129
|
+
// list chosen. Public because the card that shows Pending has to say why.
|
|
1130
|
+
"incomplete",
|
|
947
1131
|
"label",
|
|
948
1132
|
"setup",
|
|
949
1133
|
"settings",
|
|
@@ -985,6 +1169,7 @@ export {
|
|
|
985
1169
|
INPUTS,
|
|
986
1170
|
OAUTH_FIELDS,
|
|
987
1171
|
OUTCOMES,
|
|
1172
|
+
accessToken,
|
|
988
1173
|
availableConnections,
|
|
989
1174
|
build,
|
|
990
1175
|
connectFields,
|
|
@@ -993,6 +1178,7 @@ export {
|
|
|
993
1178
|
consentUrl,
|
|
994
1179
|
exchange,
|
|
995
1180
|
hookSupport,
|
|
1181
|
+
isStale,
|
|
996
1182
|
mergeSettings,
|
|
997
1183
|
pkcePair,
|
|
998
1184
|
projectConnection,
|
|
@@ -1003,5 +1189,6 @@ export {
|
|
|
1003
1189
|
resolveConnection,
|
|
1004
1190
|
runHook,
|
|
1005
1191
|
scopesMessage,
|
|
1006
|
-
stepQueues
|
|
1192
|
+
stepQueues,
|
|
1193
|
+
tokenSettings
|
|
1007
1194
|
};
|
package/package.json
CHANGED