@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.
@@ -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
- // action: /events buffers whatever arrives with the shop domain stamped on;
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": ({ action, event, headers, payload }) => {
494
- if (action === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
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: action === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
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
@@ -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.109"
204
204
  }