@drawbridge/drawbridge-utils 0.0.108 → 0.0.110

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.
@@ -26,6 +26,7 @@ __export(connections_exports, {
26
26
  INPUTS: () => INPUTS,
27
27
  OAUTH_FIELDS: () => OAUTH_FIELDS,
28
28
  OUTCOMES: () => OUTCOMES,
29
+ accessToken: () => accessToken,
29
30
  availableConnections: () => availableConnections,
30
31
  build: () => build,
31
32
  connectFields: () => connectFields,
@@ -34,6 +35,7 @@ __export(connections_exports, {
34
35
  consentUrl: () => consentUrl,
35
36
  exchange: () => exchange,
36
37
  hookSupport: () => hookSupport,
38
+ isStale: () => isStale,
37
39
  mergeSettings: () => mergeSettings,
38
40
  pkcePair: () => pkcePair,
39
41
  projectConnection: () => projectConnection,
@@ -44,7 +46,8 @@ __export(connections_exports, {
44
46
  resolveConnection: () => resolveConnection,
45
47
  runHook: () => runHook,
46
48
  scopesMessage: () => scopesMessage,
47
- stepQueues: () => stepQueues
49
+ stepQueues: () => stepQueues,
50
+ tokenSettings: () => tokenSettings
48
51
  });
49
52
  module.exports = __toCommonJS(connections_exports);
50
53
 
@@ -250,6 +253,56 @@ var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refr
250
253
  };
251
254
  };
252
255
 
256
+ // lib/connections/token.js
257
+ var SKEW_SECONDS = 120;
258
+ var isStale = (settings, now = Date.now()) => {
259
+ if (!(settings == null ? void 0 : settings.expiresAt)) return false;
260
+ return new Date(settings.expiresAt).getTime() - SKEW_SECONDS * 1e3 <= now;
261
+ };
262
+ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
263
+ accessToken: tokens.accessToken,
264
+ // A vendor that does not rotate its refresh token returns none on a refresh
265
+ // (Klaviyo). Keeping the existing one is what makes the NEXT refresh work —
266
+ // dropping it invalidates the grant one call later, nowhere near the cause.
267
+ ...(tokens.refreshToken || existing.refreshToken) && {
268
+ refreshToken: tokens.refreshToken || existing.refreshToken
269
+ },
270
+ // Absent when the vendor issues non-expiring tokens, and absent is meaningful
271
+ // — isStale reads it as "nothing to refresh toward".
272
+ ...tokens.expiresIn && {
273
+ expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
274
+ },
275
+ ...tokens.scope && { scope: tokens.scope }
276
+ });
277
+ var accessToken = async ({
278
+ clientId,
279
+ clientSecret,
280
+ fetcher,
281
+ force = false,
282
+ manifest,
283
+ now = Date.now(),
284
+ save,
285
+ settings
286
+ } = {}) => {
287
+ if (!(settings == null ? void 0 : settings.accessToken) && !(settings == null ? void 0 : settings.refreshToken)) {
288
+ throw new Error("This connection holds no credential, so there is no token to use");
289
+ }
290
+ if (!force && !isStale(settings, now)) return settings.accessToken;
291
+ if (!settings.refreshToken) {
292
+ throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
293
+ }
294
+ const minted = await refresh({
295
+ clientId,
296
+ clientSecret,
297
+ descriptor: manifest.auth.oauth,
298
+ ...fetcher && { fetcher },
299
+ refreshToken: settings.refreshToken
300
+ });
301
+ const next = tokenSettings({ existing: settings, now, tokens: minted });
302
+ if (save) await save(next);
303
+ return next.accessToken;
304
+ };
305
+
253
306
  // lib/connections/icons/klaviyo.js
254
307
  var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
255
308
  <rect width="500" height="500" fill="white"/>
@@ -257,6 +310,26 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
257
310
  </svg>`;
258
311
 
259
312
  // lib/connections/klaviyo.js
313
+ var REVISION = "2026-07-15";
314
+ var api = async (path, { fetcher = fetch, token }) => {
315
+ const response = await fetcher("https://a.klaviyo.com/api" + path, {
316
+ headers: {
317
+ // Bearer, not Klaviyo-API-Key — that header is for private keys, and
318
+ // sending it with an OAuth token fails in a way that reads like a bad
319
+ // token rather than a bad scheme.
320
+ authorization: "Bearer " + token,
321
+ revision: REVISION
322
+ },
323
+ signal: AbortSignal.timeout(15e3)
324
+ });
325
+ if (!response.ok) {
326
+ throw Object.assign(
327
+ new Error("Klaviyo refused the request (" + response.status + ")"),
328
+ { status: response.status }
329
+ );
330
+ }
331
+ return response.json();
332
+ };
260
333
  var klaviyo_default2 = {
261
334
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
262
335
  // exchange without a code_verifier matching the challenge the consent
@@ -269,7 +342,17 @@ var klaviyo_default2 = {
269
342
  // Google product wants it.
270
343
  auth: {
271
344
  oauth: {
272
- authorize: "https://a.klaviyo.com/oauth/authorize",
345
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
346
+ //
347
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
348
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
349
+ // never renders a consent screen, so the journey stalls with no error
350
+ // anybody can see.
351
+ //
352
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
353
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
354
+ // image of this mistake breaks the exchange instead of the consent.
355
+ authorize: "https://www.klaviyo.com/oauth/authorize",
273
356
  // NAMES the env vars holding OUR application's client. One identity,
274
357
  // every merchant — the token is the merchant's and arrives from their
275
358
  // own consent, which is what stops one organization reading another's
@@ -285,6 +368,11 @@ var klaviyo_default2 = {
285
368
  // it is a fact about someone else's records rather than a string this
286
369
  // code computes. Deriving one from a provider key produced
287
370
  // redirect_uri_mismatch on a connection nobody had touched.
371
+ // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
372
+ // never mentions this at runtime — you discover it when a refresh fails
373
+ // on a connection nobody touched — so it is declared, and it is why
374
+ // auth.probe has to run on a schedule rather than only before a call.
375
+ idleExpiry: 90 * 24 * 60 * 60,
288
376
  redirect: "/api/connection/klaviyo/callback",
289
377
  // Space separated. accounts:read is required by Klaviyo on every app
290
378
  // and must stay in the list; the rest are what a contact sync needs.
@@ -316,7 +404,78 @@ var klaviyo_default2 = {
316
404
  label: "Klaviyo account"
317
405
  }
318
406
  ],
407
+ // The three auth hooks, all pure HTTP against Klaviyo — which is why they
408
+ // live here rather than in sync. A vendor's own protocol belongs beside the
409
+ // vendor.
410
+ hooks: {
411
+ // Turn a fresh grant into settings worth showing. Without this the card
412
+ // renders an empty "Klaviyo account" field, because the merchant is never
413
+ // asked which account they connected — the consent already decided it and
414
+ // asking again would be a question we can answer ourselves.
415
+ "auth.connect": async ({ fetcher, tokens }) => {
416
+ var _a, _b, _c;
417
+ const body = await api("/accounts", { fetcher, token: tokens.accessToken });
418
+ const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
419
+ return {
420
+ 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,
421
+ accountId: (account == null ? void 0 : account.id) || null
422
+ };
423
+ },
424
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
425
+ // grant live in the merchant's account, so a disconnect that looks
426
+ // complete here still shows Drawbridge with access over there.
427
+ //
428
+ // Basic auth with our client, exactly like the token exchange — the token
429
+ // being revoked is the subject, not the credential.
430
+ "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
431
+ const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
432
+ if (!token) return { revoked: false };
433
+ const response = await fetcher("https://a.klaviyo.com/oauth/revoke", {
434
+ body: new URLSearchParams({
435
+ token,
436
+ token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
437
+ }),
438
+ headers: {
439
+ authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
440
+ "content-type": "application/x-www-form-urlencoded"
441
+ },
442
+ method: "POST",
443
+ signal: AbortSignal.timeout(15e3)
444
+ });
445
+ return { revoked: response.ok };
446
+ },
447
+ // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
448
+ // what we stored answers the wrong question — a grant revoked inside
449
+ // Klaviyo still looks perfect in our database. Spending the refresh token
450
+ // is the only thing that asks Klaviyo.
451
+ //
452
+ // It also keeps the grant warm: Klaviyo expires a refresh token after 90
453
+ // days of NON-USE, so a connection nobody touches dies silently without
454
+ // this running.
455
+ "auth.probe": async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
456
+ const token = await accessToken({
457
+ clientId,
458
+ clientSecret,
459
+ fetcher,
460
+ // Mint even if the stored token still looks good — a probe that
461
+ // short-circuits never reaches Klaviyo and reports healthy on a
462
+ // grant revoked an hour ago.
463
+ force: true,
464
+ manifest,
465
+ settings
466
+ });
467
+ return { ok: Boolean(token) };
468
+ }
469
+ },
319
470
  icon: klaviyo_default,
471
+ // A grant with no list chosen is authenticated and useless. The list cannot
472
+ // be part of the consent flow — enumerating lists needs the token the consent
473
+ // returns — so it is always a second step, and the card must say so rather
474
+ // than showing Active over nothing.
475
+ incomplete: (data) => {
476
+ var _a;
477
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.list) ? null : "Choose which Klaviyo list your contacts should sync into.";
478
+ },
320
479
  label: "klaviyo",
321
480
  requires: [
322
481
  "KLAVIYO_OAUTH_CLIENT_ID",
@@ -352,6 +511,16 @@ var klaviyo_default2 = {
352
511
  "lifecycle.register": false,
353
512
  "lifecycle.rehydrate": false
354
513
  },
514
+ tasks: () => [
515
+ // Mailchimp carries the same warning, deliberately worded the same way. A
516
+ // merchant who connects either one and is told nothing reasonably assumes
517
+ // contacts are flowing, and finds out weeks later that they are not.
518
+ {
519
+ 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.",
520
+ title: "List sync not available yet",
521
+ type: "warning"
522
+ }
523
+ ],
355
524
  title: "Klaviyo"
356
525
  };
357
526
 
@@ -403,6 +572,15 @@ var mailchimp_default2 = {
403
572
  }
404
573
  ],
405
574
  icon: mailchimp_default,
575
+ // A key with no audience chosen is authenticated and inert. Mailchimp also
576
+ // needs its merge fields created on that audience before any Drawbridge total
577
+ // can be written to a member — unlike Klaviyo, its custom fields are not
578
+ // schemaless — so the audience must be picked before lifecycle.register has
579
+ // anything to register against.
580
+ incomplete: (data) => {
581
+ var _a;
582
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.audience) ? null : "Choose which Mailchimp audience your contacts should sync into.";
583
+ },
406
584
  label: "mailchimp",
407
585
  // Uniform surface, honest answers. A key is stored and can be removed; nothing
408
586
  // else is built yet, because audience sync has not shipped. Every false here
@@ -532,16 +710,21 @@ var shopify_default2 = {
532
710
  }
533
711
  ],
534
712
  group: "ecommerce",
713
+ // The install is the whole configuration — Shopify hands back the shop and
714
+ // there is nothing further to choose. `shop` absent means the install did not
715
+ // finish, which is a credential problem rather than a setup one, so the
716
+ // stored status already says so.
717
+ incomplete: () => null,
535
718
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
536
719
  // is exactly the shape it covers, so there is nothing vendor-specific to
537
720
  // write for either. receive is the one hook that genuinely differs by
538
- // action: /events buffers whatever arrives with the shop domain stamped on;
721
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
539
722
  // /compliance enforces the topic allowlist above, because answering one late
540
723
  // is a legal deadline rather than a retry.
541
724
  hooks: {
542
725
  "inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
543
- "inbound.receive": ({ action, event, headers, payload }) => {
544
- if (action === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
726
+ "inbound.receive": ({ channel, event, headers, payload }) => {
727
+ if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
545
728
  throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
546
729
  }
547
730
  return {
@@ -549,7 +732,7 @@ var shopify_default2 = {
549
732
  // own GDPR shape. The app-level event stream does not; that domain
550
733
  // lives only in the header, so it is stamped on here rather than left
551
734
  // for drawbridge-sync to reach into headers nobody hands it.
552
- data: action === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
735
+ data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
553
736
  provider: { id: headers[inbound.headers.id] || null }
554
737
  };
555
738
  },
@@ -731,6 +914,9 @@ var webhook_default = {
731
914
  // It is the one card that reads wrong — our logo among vendor logos — and it
732
915
  // wants a mark of its own when there is one.
733
916
  icon: drawbridge_default,
917
+ // The destination url is supplied per step, not per connection, so there is
918
+ // nothing to finish here — generating the secret IS connecting.
919
+ incomplete: () => null,
734
920
  label: "webhook",
735
921
  // Gated on the encryption secret: without it the signing secret could not be
736
922
  // stored safely, so the connection must not be offered at all.
@@ -838,6 +1024,11 @@ var build = (manifest) => {
838
1024
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
839
1025
  }
840
1026
  }
1027
+ if (manifest.auth.oauth.redirect !== "/api/connection/" + manifest.slug + "/callback") {
1028
+ throw new Error(
1029
+ manifest.slug + " declares auth.oauth.redirect " + manifest.auth.oauth.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
1030
+ );
1031
+ }
841
1032
  }
842
1033
  if (((_d = manifest.supports) == null ? void 0 : _d["inbound.event"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.event)) {
843
1034
  throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
@@ -845,6 +1036,9 @@ var build = (manifest) => {
845
1036
  if (((_g = manifest.supports) == null ? void 0 : _g["inbound.verify"]) && !((_i = (_h = manifest.inbound) == null ? void 0 : _h.headers) == null ? void 0 : _i.signature)) {
846
1037
  throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
847
1038
  }
1039
+ if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
1040
+ throw new Error(manifest.slug + " must declare incomplete( data ) \u2014 return null when the connection is usable, or the reason it is not");
1041
+ }
848
1042
  if (!Array.isArray(manifest == null ? void 0 : manifest.setup) || !manifest.setup.length) {
849
1043
  throw new Error(manifest.slug + " needs a setup guide \u2014 an array of steps for its page");
850
1044
  }
@@ -994,6 +1188,9 @@ var publicConnectionKeys = Object.freeze([
994
1188
  "group",
995
1189
  "id",
996
1190
  "image",
1191
+ // The reason a connected vendor still is not usable — a Klaviyo grant with no
1192
+ // list chosen. Public because the card that shows Pending has to say why.
1193
+ "incomplete",
997
1194
  "label",
998
1195
  "setup",
999
1196
  "settings",
@@ -1036,6 +1233,7 @@ var resolveConnection = (item, data) => {
1036
1233
  INPUTS,
1037
1234
  OAUTH_FIELDS,
1038
1235
  OUTCOMES,
1236
+ accessToken,
1039
1237
  availableConnections,
1040
1238
  build,
1041
1239
  connectFields,
@@ -1044,6 +1242,7 @@ var resolveConnection = (item, data) => {
1044
1242
  consentUrl,
1045
1243
  exchange,
1046
1244
  hookSupport,
1245
+ isStale,
1047
1246
  mergeSettings,
1048
1247
  pkcePair,
1049
1248
  projectConnection,
@@ -1054,5 +1253,6 @@ var resolveConnection = (item, data) => {
1054
1253
  resolveConnection,
1055
1254
  runHook,
1056
1255
  scopesMessage,
1057
- stepQueues
1256
+ stepQueues,
1257
+ tokenSettings
1058
1258
  });