@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.
@@ -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
@@ -219,7 +289,17 @@ var klaviyo_default2 = {
219
289
  // Google product wants it.
220
290
  auth: {
221
291
  oauth: {
222
- authorize: "https://a.klaviyo.com/oauth/authorize",
292
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
293
+ //
294
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
295
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
296
+ // never renders a consent screen, so the journey stalls with no error
297
+ // anybody can see.
298
+ //
299
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
300
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
301
+ // image of this mistake breaks the exchange instead of the consent.
302
+ authorize: "https://www.klaviyo.com/oauth/authorize",
223
303
  // NAMES the env vars holding OUR application's client. One identity,
224
304
  // every merchant — the token is the merchant's and arrives from their
225
305
  // own consent, which is what stops one organization reading another's
@@ -235,6 +315,11 @@ var klaviyo_default2 = {
235
315
  // it is a fact about someone else's records rather than a string this
236
316
  // code computes. Deriving one from a provider key produced
237
317
  // redirect_uri_mismatch on a connection nobody had touched.
318
+ // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
319
+ // never mentions this at runtime — you discover it when a refresh fails
320
+ // on a connection nobody touched — so it is declared, and it is why
321
+ // auth.probe has to run on a schedule rather than only before a call.
322
+ idleExpiry: 90 * 24 * 60 * 60,
238
323
  redirect: "/api/connection/klaviyo/callback",
239
324
  // Space separated. accounts:read is required by Klaviyo on every app
240
325
  // and must stay in the list; the rest are what a contact sync needs.
@@ -266,7 +351,78 @@ var klaviyo_default2 = {
266
351
  label: "Klaviyo account"
267
352
  }
268
353
  ],
354
+ // The three auth hooks, all pure HTTP against Klaviyo — which is why they
355
+ // live here rather than in sync. A vendor's own protocol belongs beside the
356
+ // vendor.
357
+ hooks: {
358
+ // Turn a fresh grant into settings worth showing. Without this the card
359
+ // renders an empty "Klaviyo account" field, because the merchant is never
360
+ // asked which account they connected — the consent already decided it and
361
+ // asking again would be a question we can answer ourselves.
362
+ "auth.connect": async ({ fetcher, tokens }) => {
363
+ var _a, _b, _c;
364
+ const body = await api("/accounts", { fetcher, token: tokens.accessToken });
365
+ const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
366
+ return {
367
+ 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,
368
+ accountId: (account == null ? void 0 : account.id) || null
369
+ };
370
+ },
371
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
372
+ // grant live in the merchant's account, so a disconnect that looks
373
+ // complete here still shows Drawbridge with access over there.
374
+ //
375
+ // Basic auth with our client, exactly like the token exchange — the token
376
+ // being revoked is the subject, not the credential.
377
+ "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
378
+ const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
379
+ if (!token) return { revoked: false };
380
+ const response = await fetcher("https://a.klaviyo.com/oauth/revoke", {
381
+ body: new URLSearchParams({
382
+ token,
383
+ token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
384
+ }),
385
+ headers: {
386
+ authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
387
+ "content-type": "application/x-www-form-urlencoded"
388
+ },
389
+ method: "POST",
390
+ signal: AbortSignal.timeout(15e3)
391
+ });
392
+ return { revoked: response.ok };
393
+ },
394
+ // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
395
+ // what we stored answers the wrong question — a grant revoked inside
396
+ // Klaviyo still looks perfect in our database. Spending the refresh token
397
+ // is the only thing that asks Klaviyo.
398
+ //
399
+ // It also keeps the grant warm: Klaviyo expires a refresh token after 90
400
+ // days of NON-USE, so a connection nobody touches dies silently without
401
+ // this running.
402
+ "auth.probe": async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
403
+ const token = await accessToken({
404
+ clientId,
405
+ clientSecret,
406
+ fetcher,
407
+ // Mint even if the stored token still looks good — a probe that
408
+ // short-circuits never reaches Klaviyo and reports healthy on a
409
+ // grant revoked an hour ago.
410
+ force: true,
411
+ manifest,
412
+ settings
413
+ });
414
+ return { ok: Boolean(token) };
415
+ }
416
+ },
269
417
  icon: klaviyo_default,
418
+ // A grant with no list chosen is authenticated and useless. The list cannot
419
+ // be part of the consent flow — enumerating lists needs the token the consent
420
+ // returns — so it is always a second step, and the card must say so rather
421
+ // than showing Active over nothing.
422
+ incomplete: (data) => {
423
+ var _a;
424
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.list) ? null : "Choose which Klaviyo list your contacts should sync into.";
425
+ },
270
426
  label: "klaviyo",
271
427
  requires: [
272
428
  "KLAVIYO_OAUTH_CLIENT_ID",
@@ -302,6 +458,16 @@ var klaviyo_default2 = {
302
458
  "lifecycle.register": false,
303
459
  "lifecycle.rehydrate": false
304
460
  },
461
+ tasks: () => [
462
+ // Mailchimp carries the same warning, deliberately worded the same way. A
463
+ // merchant who connects either one and is told nothing reasonably assumes
464
+ // contacts are flowing, and finds out weeks later that they are not.
465
+ {
466
+ 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.",
467
+ title: "List sync not available yet",
468
+ type: "warning"
469
+ }
470
+ ],
305
471
  title: "Klaviyo"
306
472
  };
307
473
 
@@ -353,6 +519,15 @@ var mailchimp_default2 = {
353
519
  }
354
520
  ],
355
521
  icon: mailchimp_default,
522
+ // A key with no audience chosen is authenticated and inert. Mailchimp also
523
+ // needs its merge fields created on that audience before any Drawbridge total
524
+ // can be written to a member — unlike Klaviyo, its custom fields are not
525
+ // schemaless — so the audience must be picked before lifecycle.register has
526
+ // anything to register against.
527
+ incomplete: (data) => {
528
+ var _a;
529
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.audience) ? null : "Choose which Mailchimp audience your contacts should sync into.";
530
+ },
356
531
  label: "mailchimp",
357
532
  // Uniform surface, honest answers. A key is stored and can be removed; nothing
358
533
  // else is built yet, because audience sync has not shipped. Every false here
@@ -482,16 +657,21 @@ var shopify_default2 = {
482
657
  }
483
658
  ],
484
659
  group: "ecommerce",
660
+ // The install is the whole configuration — Shopify hands back the shop and
661
+ // there is nothing further to choose. `shop` absent means the install did not
662
+ // finish, which is a credential problem rather than a setup one, so the
663
+ // stored status already says so.
664
+ incomplete: () => null,
485
665
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
486
666
  // is exactly the shape it covers, so there is nothing vendor-specific to
487
667
  // write for either. receive is the one hook that genuinely differs by
488
- // action: /events buffers whatever arrives with the shop domain stamped on;
668
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
489
669
  // /compliance enforces the topic allowlist above, because answering one late
490
670
  // is a legal deadline rather than a retry.
491
671
  hooks: {
492
672
  "inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
493
- "inbound.receive": ({ action, event, headers, payload }) => {
494
- if (action === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
673
+ "inbound.receive": ({ channel, event, headers, payload }) => {
674
+ if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
495
675
  throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
496
676
  }
497
677
  return {
@@ -499,7 +679,7 @@ var shopify_default2 = {
499
679
  // own GDPR shape. The app-level event stream does not; that domain
500
680
  // lives only in the header, so it is stamped on here rather than left
501
681
  // for drawbridge-sync to reach into headers nobody hands it.
502
- data: action === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
682
+ data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
503
683
  provider: { id: headers[inbound.headers.id] || null }
504
684
  };
505
685
  },
@@ -681,6 +861,9 @@ var webhook_default = {
681
861
  // It is the one card that reads wrong — our logo among vendor logos — and it
682
862
  // wants a mark of its own when there is one.
683
863
  icon: drawbridge_default,
864
+ // The destination url is supplied per step, not per connection, so there is
865
+ // nothing to finish here — generating the secret IS connecting.
866
+ incomplete: () => null,
684
867
  label: "webhook",
685
868
  // Gated on the encryption secret: without it the signing secret could not be
686
869
  // stored safely, so the connection must not be offered at all.
@@ -788,6 +971,11 @@ var build = (manifest) => {
788
971
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
789
972
  }
790
973
  }
974
+ if (manifest.auth.oauth.redirect !== "/api/connection/" + manifest.slug + "/callback") {
975
+ throw new Error(
976
+ manifest.slug + " declares auth.oauth.redirect " + manifest.auth.oauth.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
977
+ );
978
+ }
791
979
  }
792
980
  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
981
  throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
@@ -795,6 +983,9 @@ var build = (manifest) => {
795
983
  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
984
  throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
797
985
  }
986
+ if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
987
+ throw new Error(manifest.slug + " must declare incomplete( data ) \u2014 return null when the connection is usable, or the reason it is not");
988
+ }
798
989
  if (!Array.isArray(manifest == null ? void 0 : manifest.setup) || !manifest.setup.length) {
799
990
  throw new Error(manifest.slug + " needs a setup guide \u2014 an array of steps for its page");
800
991
  }
@@ -944,6 +1135,9 @@ var publicConnectionKeys = Object.freeze([
944
1135
  "group",
945
1136
  "id",
946
1137
  "image",
1138
+ // The reason a connected vendor still is not usable — a Klaviyo grant with no
1139
+ // list chosen. Public because the card that shows Pending has to say why.
1140
+ "incomplete",
947
1141
  "label",
948
1142
  "setup",
949
1143
  "settings",
@@ -985,6 +1179,7 @@ export {
985
1179
  INPUTS,
986
1180
  OAUTH_FIELDS,
987
1181
  OUTCOMES,
1182
+ accessToken,
988
1183
  availableConnections,
989
1184
  build,
990
1185
  connectFields,
@@ -993,6 +1188,7 @@ export {
993
1188
  consentUrl,
994
1189
  exchange,
995
1190
  hookSupport,
1191
+ isStale,
996
1192
  mergeSettings,
997
1193
  pkcePair,
998
1194
  projectConnection,
@@ -1003,5 +1199,6 @@ export {
1003
1199
  resolveConnection,
1004
1200
  runHook,
1005
1201
  scopesMessage,
1006
- stepQueues
1202
+ stepQueues,
1203
+ tokenSettings
1007
1204
  };
package/package.json CHANGED
@@ -200,5 +200,5 @@
200
200
  "test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
201
201
  },
202
202
  "types": "dist/index.d.ts",
203
- "version": "0.0.108"
203
+ "version": "0.0.110"
204
204
  }