@drawbridge/drawbridge-utils 0.0.107 → 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.
@@ -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
 
@@ -76,14 +79,49 @@ var HOOKS = Object.freeze({
76
79
  "cleanup"
77
80
  ]),
78
81
  // Receiving from the vendor.
82
+ //
83
+ // Split across TWO SERVICES, and the split is the point. `verify` and `event`
84
+ // and `receive` run in drawbridge-webhooks against the vendor's open
85
+ // connection, where the budget is whatever that vendor's timeout is —
86
+ // Shopify's is about five seconds, and missing it means they retry and the
87
+ // delivery is buffered twice. `process` runs in drawbridge-sync off the
88
+ // buffer, where it can take as long as it needs and retry without the vendor
89
+ // ever knowing.
90
+ //
91
+ // One hook spanning that seam would hide it, and the thing it hides is the
92
+ // one most likely to bite: slow work written on the receiving side turns
93
+ // into duplicate deliveries.
79
94
  inbound: Object.freeze([
80
- // Prove the request came from the vendor. Signature schemes differ per
81
- // vendor, which is exactly why this is a hook and not one shared function.
95
+ // Prove the request came from the vendor, AND return the payload it
96
+ // carries. One hook rather than two because for some vendors they are
97
+ // genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
98
+ // into anything trustworthy without verifying it first, and a decode that
99
+ // runs before the signature check is exactly the bug this shape prevents.
100
+ //
101
+ // So the raw bytes stop here. Everything downstream receives the payload
102
+ // this returned, which means nothing downstream can act on unverified
103
+ // data even by mistake.
104
+ //
105
+ // It is also where a request gets refused for any other reason — an event
106
+ // outside the allowlist, a connection in the wrong state. Anything that
107
+ // can reject belongs here, so the route holds no rules of its own.
82
108
  "verify",
83
- // Name the event, from wherever this vendor puts it.
84
- "topic",
85
- // Do the work the event implies.
86
- "handle"
109
+ // Name the event, from wherever this vendor puts it. A header for
110
+ // Shopify, the route itself for Twilio, a claim in the payload for a
111
+ // JWT-bodied vendor.
112
+ "event",
113
+ // What to buffer and what to answer RIGHT NOW, while the vendor waits.
114
+ // Thin by obligation. Most vendors only shape a buffer doc here; one that
115
+ // must reply with content (Twilio answers HELP inline with TwiML, which
116
+ // carriers require) returns that too.
117
+ "receive",
118
+ // Do the work the event implies. Runs in drawbridge-sync, off the buffer.
119
+ //
120
+ // DECLARED here, IMPLEMENTED there — the same split as the step handlers,
121
+ // and for the same reason: this half needs controllers, queues and vendor
122
+ // SDKs, and putting those behind a published package makes every consumer
123
+ // carry them. The declaration is what proves the implementation exists.
124
+ "process"
87
125
  ]),
88
126
  // Vendor data a campaign draws on. Named for what every store platform has,
89
127
  // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
@@ -215,6 +253,56 @@ var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refr
215
253
  };
216
254
  };
217
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
+
218
306
  // lib/connections/icons/klaviyo.js
219
307
  var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
220
308
  <rect width="500" height="500" fill="white"/>
@@ -222,6 +310,26 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
222
310
  </svg>`;
223
311
 
224
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
+ };
225
333
  var klaviyo_default2 = {
226
334
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
227
335
  // exchange without a code_verifier matching the challenge the consent
@@ -250,6 +358,11 @@ var klaviyo_default2 = {
250
358
  // it is a fact about someone else's records rather than a string this
251
359
  // code computes. Deriving one from a provider key produced
252
360
  // redirect_uri_mismatch on a connection nobody had touched.
361
+ // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
362
+ // never mentions this at runtime — you discover it when a refresh fails
363
+ // on a connection nobody touched — so it is declared, and it is why
364
+ // auth.probe has to run on a schedule rather than only before a call.
365
+ idleExpiry: 90 * 24 * 60 * 60,
253
366
  redirect: "/api/connection/klaviyo/callback",
254
367
  // Space separated. accounts:read is required by Klaviyo on every app
255
368
  // and must stay in the list; the rest are what a contact sync needs.
@@ -281,7 +394,78 @@ var klaviyo_default2 = {
281
394
  label: "Klaviyo account"
282
395
  }
283
396
  ],
397
+ // The three auth hooks, all pure HTTP against Klaviyo — which is why they
398
+ // live here rather than in sync. A vendor's own protocol belongs beside the
399
+ // vendor.
400
+ hooks: {
401
+ // Turn a fresh grant into settings worth showing. Without this the card
402
+ // renders an empty "Klaviyo account" field, because the merchant is never
403
+ // asked which account they connected — the consent already decided it and
404
+ // asking again would be a question we can answer ourselves.
405
+ "auth.connect": async ({ fetcher, tokens }) => {
406
+ var _a, _b, _c;
407
+ const body = await api("/accounts", { fetcher, token: tokens.accessToken });
408
+ const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
409
+ return {
410
+ 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,
411
+ accountId: (account == null ? void 0 : account.id) || null
412
+ };
413
+ },
414
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
415
+ // grant live in the merchant's account, so a disconnect that looks
416
+ // complete here still shows Drawbridge with access over there.
417
+ //
418
+ // Basic auth with our client, exactly like the token exchange — the token
419
+ // being revoked is the subject, not the credential.
420
+ "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
421
+ const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
422
+ if (!token) return { revoked: false };
423
+ const response = await fetcher("https://a.klaviyo.com/oauth/revoke", {
424
+ body: new URLSearchParams({
425
+ token,
426
+ token_type_hint: (settings == null ? void 0 : settings.refreshToken) ? "refresh_token" : "access_token"
427
+ }),
428
+ headers: {
429
+ authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64"),
430
+ "content-type": "application/x-www-form-urlencoded"
431
+ },
432
+ method: "POST",
433
+ signal: AbortSignal.timeout(15e3)
434
+ });
435
+ return { revoked: response.ok };
436
+ },
437
+ // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
438
+ // what we stored answers the wrong question — a grant revoked inside
439
+ // Klaviyo still looks perfect in our database. Spending the refresh token
440
+ // is the only thing that asks Klaviyo.
441
+ //
442
+ // It also keeps the grant warm: Klaviyo expires a refresh token after 90
443
+ // days of NON-USE, so a connection nobody touches dies silently without
444
+ // this running.
445
+ "auth.probe": async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
446
+ const token = await accessToken({
447
+ clientId,
448
+ clientSecret,
449
+ fetcher,
450
+ // Mint even if the stored token still looks good — a probe that
451
+ // short-circuits never reaches Klaviyo and reports healthy on a
452
+ // grant revoked an hour ago.
453
+ force: true,
454
+ manifest,
455
+ settings
456
+ });
457
+ return { ok: Boolean(token) };
458
+ }
459
+ },
284
460
  icon: klaviyo_default,
461
+ // A grant with no list chosen is authenticated and useless. The list cannot
462
+ // be part of the consent flow — enumerating lists needs the token the consent
463
+ // returns — so it is always a second step, and the card must say so rather
464
+ // than showing Active over nothing.
465
+ incomplete: (data) => {
466
+ var _a;
467
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.list) ? null : "Choose which Klaviyo list your contacts should sync into.";
468
+ },
285
469
  label: "klaviyo",
286
470
  requires: [
287
471
  "KLAVIYO_OAUTH_CLIENT_ID",
@@ -309,13 +493,24 @@ var klaviyo_default2 = {
309
493
  "catalog.prices": false,
310
494
  "catalog.products": false,
311
495
  "catalog.promotions": false,
312
- "inbound.handle": false,
313
- "inbound.topic": false,
496
+ "inbound.event": false,
497
+ "inbound.process": false,
498
+ "inbound.receive": false,
314
499
  "inbound.verify": false,
315
500
  "lifecycle.cleanup": false,
316
501
  "lifecycle.register": false,
317
502
  "lifecycle.rehydrate": false
318
503
  },
504
+ tasks: () => [
505
+ // Mailchimp carries the same warning, deliberately worded the same way. A
506
+ // merchant who connects either one and is told nothing reasonably assumes
507
+ // contacts are flowing, and finds out weeks later that they are not.
508
+ {
509
+ 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.",
510
+ title: "List sync not available yet",
511
+ type: "warning"
512
+ }
513
+ ],
319
514
  title: "Klaviyo"
320
515
  };
321
516
 
@@ -367,6 +562,15 @@ var mailchimp_default2 = {
367
562
  }
368
563
  ],
369
564
  icon: mailchimp_default,
565
+ // A key with no audience chosen is authenticated and inert. Mailchimp also
566
+ // needs its merge fields created on that audience before any Drawbridge total
567
+ // can be written to a member — unlike Klaviyo, its custom fields are not
568
+ // schemaless — so the audience must be picked before lifecycle.register has
569
+ // anything to register against.
570
+ incomplete: (data) => {
571
+ var _a;
572
+ return ((_a = data == null ? void 0 : data.settings) == null ? void 0 : _a.audience) ? null : "Choose which Mailchimp audience your contacts should sync into.";
573
+ },
370
574
  label: "mailchimp",
371
575
  // Uniform surface, honest answers. A key is stored and can be removed; nothing
372
576
  // else is built yet, because audience sync has not shipped. Every false here
@@ -380,8 +584,9 @@ var mailchimp_default2 = {
380
584
  "catalog.prices": false,
381
585
  "catalog.products": false,
382
586
  "catalog.promotions": false,
383
- "inbound.handle": false,
384
- "inbound.topic": false,
587
+ "inbound.event": false,
588
+ "inbound.process": false,
589
+ "inbound.receive": false,
385
590
  "inbound.verify": false,
386
591
  "lifecycle.cleanup": false,
387
592
  "lifecycle.register": false,
@@ -415,7 +620,42 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
415
620
  <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"/>
416
621
  </svg>`;
417
622
 
623
+ // lib/connections/inbound.js
624
+ var import_node_crypto2 = require("crypto");
625
+ var verifySignature = ({ body, descriptor, headers }) => {
626
+ const provided = headers[descriptor.headers.signature];
627
+ if (!provided) {
628
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
629
+ }
630
+ const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
631
+ const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
632
+ const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
633
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
634
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
635
+ }
636
+ return JSON.parse(body.toString());
637
+ };
638
+ var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
639
+
418
640
  // lib/connections/shopify.js
641
+ var inbound = {
642
+ headers: {
643
+ event: "x-shopify-topic",
644
+ id: "x-shopify-webhook-id",
645
+ shop: "x-shopify-shop-domain",
646
+ signature: "x-shopify-hmac-sha256"
647
+ },
648
+ signature: {
649
+ algorithm: "sha256",
650
+ encoding: "base64",
651
+ secret: "SHOPIFY_API_SECRET"
652
+ }
653
+ };
654
+ var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
655
+ "customers/data_request",
656
+ "customers/redact",
657
+ "shop/redact"
658
+ ]);
419
659
  var shopify_default2 = {
420
660
  // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
421
661
  // sees a consent screen we sent them to -- they start at the App Store, and
@@ -460,30 +700,36 @@ var shopify_default2 = {
460
700
  }
461
701
  ],
462
702
  group: "ecommerce",
463
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
464
- // about someone else's product, so they belong beside the rest of the vendor
465
- // rather than as string literals in a route which is where they were, and
466
- // is why a second inbound vendor meant a second route file.
467
- //
468
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
469
- // raw body with the named secret and compare, constant-time, against the
470
- // header. Vendors whose scheme is not that shape Stripe signs a timestamped
471
- // payload declare no signature block and implement inbound.verify instead.
472
- // That is why verify is a hook and not config.
473
- inbound: {
474
- headers: {
475
- id: "x-shopify-webhook-id",
476
- shop: "x-shopify-shop-domain",
477
- signature: "x-shopify-hmac-sha256",
478
- topic: "x-shopify-topic"
703
+ // The install is the whole configuration Shopify hands back the shop and
704
+ // there is nothing further to choose. `shop` absent means the install did not
705
+ // finish, which is a credential problem rather than a setup one, so the
706
+ // stored status already says so.
707
+ incomplete: () => null,
708
+ // verify and event lean entirely on the shared HMAC helper Shopify's scheme
709
+ // is exactly the shape it covers, so there is nothing vendor-specific to
710
+ // write for either. receive is the one hook that genuinely differs by
711
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
712
+ // /compliance enforces the topic allowlist above, because answering one late
713
+ // is a legal deadline rather than a retry.
714
+ hooks: {
715
+ "inbound.event": (args) => readEventHeader({ ...args, descriptor: inbound }),
716
+ "inbound.receive": ({ channel, event, headers, payload }) => {
717
+ if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
718
+ throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
719
+ }
720
+ return {
721
+ // Compliance payloads already carry shop_domain in the body — Shopify's
722
+ // own GDPR shape. The app-level event stream does not; that domain
723
+ // lives only in the header, so it is stamped on here rather than left
724
+ // for drawbridge-sync to reach into headers nobody hands it.
725
+ data: channel === "compliance" ? payload : { ...payload, shop_domain: headers[inbound.headers.shop] || null },
726
+ provider: { id: headers[inbound.headers.id] || null }
727
+ };
479
728
  },
480
- signature: {
481
- algorithm: "sha256",
482
- encoding: "base64",
483
- secret: "SHOPIFY_API_SECRET"
484
- }
729
+ "inbound.verify": (args) => verifySignature({ ...args, descriptor: inbound })
485
730
  },
486
731
  icon: shopify_default,
732
+ inbound,
487
733
  label: "shopify",
488
734
  // A pre-launch integration: it only surfaces once the App Store listing
489
735
  // exists and the app is fully configured. Requiring all four means it can
@@ -512,8 +758,9 @@ var shopify_default2 = {
512
758
  "catalog.prices": false,
513
759
  "catalog.products": true,
514
760
  "catalog.promotions": true,
515
- "inbound.handle": true,
516
- "inbound.topic": true,
761
+ "inbound.event": true,
762
+ "inbound.process": true,
763
+ "inbound.receive": true,
517
764
  "inbound.verify": true,
518
765
  "lifecycle.cleanup": true,
519
766
  "lifecycle.register": true,
@@ -657,6 +904,9 @@ var webhook_default = {
657
904
  // It is the one card that reads wrong — our logo among vendor logos — and it
658
905
  // wants a mark of its own when there is one.
659
906
  icon: drawbridge_default,
907
+ // The destination url is supplied per step, not per connection, so there is
908
+ // nothing to finish here — generating the secret IS connecting.
909
+ incomplete: () => null,
660
910
  label: "webhook",
661
911
  // Gated on the encryption secret: without it the signing secret could not be
662
912
  // stored safely, so the connection must not be offered at all.
@@ -673,8 +923,9 @@ var webhook_default = {
673
923
  "catalog.prices": false,
674
924
  "catalog.products": false,
675
925
  "catalog.promotions": false,
676
- "inbound.handle": false,
677
- "inbound.topic": false,
926
+ "inbound.event": false,
927
+ "inbound.process": false,
928
+ "inbound.receive": false,
678
929
  "inbound.verify": false,
679
930
  "lifecycle.cleanup": false,
680
931
  "lifecycle.register": false,
@@ -763,13 +1014,21 @@ var build = (manifest) => {
763
1014
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
764
1015
  }
765
1016
  }
1017
+ if (manifest.auth.oauth.redirect !== "/api/connection/" + manifest.slug + "/callback") {
1018
+ throw new Error(
1019
+ manifest.slug + " declares auth.oauth.redirect " + manifest.auth.oauth.redirect + " but the only callback route is /api/connection/" + manifest.slug + "/callback"
1020
+ );
1021
+ }
766
1022
  }
767
- if (((_d = manifest.supports) == null ? void 0 : _d["inbound.topic"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.topic)) {
768
- throw new Error(manifest.slug + " supports inbound.topic but declares no inbound.headers.topic");
1023
+ if (((_d = manifest.supports) == null ? void 0 : _d["inbound.event"]) && !((_f = (_e = manifest.inbound) == null ? void 0 : _e.headers) == null ? void 0 : _f.event)) {
1024
+ throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
769
1025
  }
770
1026
  if (((_g = manifest.supports) == null ? void 0 : _g["inbound.verify"]) && !((_i = (_h = manifest.inbound) == null ? void 0 : _h.headers) == null ? void 0 : _i.signature)) {
771
1027
  throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
772
1028
  }
1029
+ if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
1030
+ throw new Error(manifest.slug + " must declare incomplete( data ) \u2014 return null when the connection is usable, or the reason it is not");
1031
+ }
773
1032
  if (!Array.isArray(manifest == null ? void 0 : manifest.setup) || !manifest.setup.length) {
774
1033
  throw new Error(manifest.slug + " needs a setup guide \u2014 an array of steps for its page");
775
1034
  }
@@ -880,7 +1139,7 @@ var runHook = async (slug, name, args = {}) => {
880
1139
  try {
881
1140
  return { outcome: OUTCOMES.answered, result: await hook(args) };
882
1141
  } catch (error) {
883
- return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed };
1142
+ return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
884
1143
  }
885
1144
  };
886
1145
  var stepQueues = (env = {}) => Object.fromEntries(
@@ -919,6 +1178,9 @@ var publicConnectionKeys = Object.freeze([
919
1178
  "group",
920
1179
  "id",
921
1180
  "image",
1181
+ // The reason a connected vendor still is not usable — a Klaviyo grant with no
1182
+ // list chosen. Public because the card that shows Pending has to say why.
1183
+ "incomplete",
922
1184
  "label",
923
1185
  "setup",
924
1186
  "settings",
@@ -946,7 +1208,7 @@ var projectConnection = (record) => {
946
1208
  var resolveConnection = (item, data) => {
947
1209
  if (!item) return item;
948
1210
  return Object.fromEntries(
949
- Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
1211
+ Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "requires", "steps", "supports"].includes(key)).map(([key, value]) => [
950
1212
  key,
951
1213
  typeof value === "function" ? value(data) : value
952
1214
  ])
@@ -961,6 +1223,7 @@ var resolveConnection = (item, data) => {
961
1223
  INPUTS,
962
1224
  OAUTH_FIELDS,
963
1225
  OUTCOMES,
1226
+ accessToken,
964
1227
  availableConnections,
965
1228
  build,
966
1229
  connectFields,
@@ -969,6 +1232,7 @@ var resolveConnection = (item, data) => {
969
1232
  consentUrl,
970
1233
  exchange,
971
1234
  hookSupport,
1235
+ isStale,
972
1236
  mergeSettings,
973
1237
  pkcePair,
974
1238
  projectConnection,
@@ -979,5 +1243,6 @@ var resolveConnection = (item, data) => {
979
1243
  resolveConnection,
980
1244
  runHook,
981
1245
  scopesMessage,
982
- stepQueues
1246
+ stepQueues,
1247
+ tokenSettings
983
1248
  });