@drawbridge/drawbridge-utils 0.0.109 → 0.0.111

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.
@@ -127,6 +127,12 @@ var HOOKS = Object.freeze({
127
127
  // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
128
128
  // and promotion codes, BigCommerce says coupons and promotions.
129
129
  catalog: Object.freeze([
130
+ // The named groups a contact can be synced INTO. Klaviyo calls them lists,
131
+ // Mailchimp calls them audiences; `audiences` is the industry-generic term
132
+ // and belongs to neither vendor's API. Read at form time, so a merchant
133
+ // picks from what actually exists in their account rather than pasting an
134
+ // id from another tab.
135
+ "audiences",
130
136
  "products",
131
137
  // Shopify folds price into the product variant; Stripe makes Price a
132
138
  // first-class object beside Product. Declared so a vendor that separates
@@ -342,7 +348,17 @@ var klaviyo_default2 = {
342
348
  // Google product wants it.
343
349
  auth: {
344
350
  oauth: {
345
- authorize: "https://a.klaviyo.com/oauth/authorize",
351
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
352
+ //
353
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
354
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
355
+ // never renders a consent screen, so the journey stalls with no error
356
+ // anybody can see.
357
+ //
358
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
359
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
360
+ // image of this mistake breaks the exchange instead of the consent.
361
+ authorize: "https://www.klaviyo.com/oauth/authorize",
346
362
  // NAMES the env vars holding OUR application's client. One identity,
347
363
  // every merchant — the token is the merchant's and arrives from their
348
364
  // own consent, which is what stops one organization reading another's
@@ -392,6 +408,17 @@ var klaviyo_default2 = {
392
408
  {
393
409
  key: "account",
394
410
  label: "Klaviyo account"
411
+ },
412
+ {
413
+ input: "select",
414
+ key: "list",
415
+ label: "Klaviyo list",
416
+ message: "Contacts your campaigns collect are synced into this list.",
417
+ required: true,
418
+ // The choices come from the merchant's own account, not from here — see
419
+ // catalog.audiences below. Static options would mean asking somebody to
420
+ // paste a list id copied out of another browser tab.
421
+ source: "catalog.audiences"
395
422
  }
396
423
  ],
397
424
  // The three auth hooks, all pure HTTP against Klaviyo — which is why they
@@ -417,6 +444,29 @@ var klaviyo_default2 = {
417
444
  //
418
445
  // Basic auth with our client, exactly like the token exchange — the token
419
446
  // being revoked is the subject, not the credential.
447
+ // The lists a merchant can sync into, for the picker on their connection.
448
+ //
449
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
450
+ // so a single call quietly returns the first ten lists and an account with
451
+ // more would show a picker missing the one they wanted — with nothing to
452
+ // indicate anything was cut. Follows links.next, bounded so a runaway
453
+ // cursor cannot spin forever.
454
+ "catalog.audiences": async ({ fetcher, token }) => {
455
+ var _a, _b;
456
+ const audiences = [];
457
+ let path = "/lists?page%5Bsize%5D=10";
458
+ let pages = 0;
459
+ while (path && pages < 20) {
460
+ const body = await api(path, { fetcher, token });
461
+ for (const list of (body == null ? void 0 : body.data) || []) {
462
+ audiences.push({ label: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id, value: list.id });
463
+ }
464
+ const next = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
465
+ path = next ? String(next).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
466
+ pages = pages + 1;
467
+ }
468
+ return audiences;
469
+ },
420
470
  "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
421
471
  const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
422
472
  if (!token) return { revoked: false };
@@ -490,6 +540,7 @@ var klaviyo_default2 = {
490
540
  "auth.probe": true,
491
541
  // Klaviyo scopes are fixed at app level and re-consented, not drifted.
492
542
  "auth.scopes": false,
543
+ "catalog.audiences": true,
493
544
  "catalog.prices": false,
494
545
  "catalog.products": false,
495
546
  "catalog.promotions": false,
@@ -522,6 +573,11 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
522
573
  </svg>`;
523
574
 
524
575
  // lib/connections/mailchimp.js
576
+ var base = (apiKey) => {
577
+ const dc = String(apiKey || "").split("-").pop();
578
+ if (!dc || dc === apiKey) throw new Error("That Mailchimp key carries no data centre suffix, so there is no host to call");
579
+ return "https://" + dc + ".api.mailchimp.com/3.0";
580
+ };
525
581
  var mailchimp_default2 = {
526
582
  // Keys TODAY. Mailchimp integrations authenticate with OAuth 2 (authorization
527
583
  // code) and that is where this goes, so the endpoints are recorded here
@@ -559,8 +615,53 @@ var mailchimp_default2 = {
559
615
  placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022-us1",
560
616
  redact: true,
561
617
  required: true
618
+ },
619
+ {
620
+ input: "select",
621
+ key: "audience",
622
+ label: "Mailchimp audience",
623
+ message: "Contacts your campaigns collect are synced into this audience.",
624
+ required: true,
625
+ source: "catalog.audiences"
562
626
  }
563
627
  ],
628
+ // The audiences a merchant can sync into, for the picker on their connection.
629
+ //
630
+ // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
631
+ // it unset returns the first ten audiences and looks entirely successful —
632
+ // the same silent truncation Klaviyo has, at a different number. Paged
633
+ // against total_items so an account past a thousand still resolves.
634
+ hooks: {
635
+ "catalog.audiences": async ({ fetcher = fetch, settings }) => {
636
+ const key = settings == null ? void 0 : settings.apiKey;
637
+ const headers = {
638
+ // Basic with any username — Mailchimp reads only the password half.
639
+ authorization: "Basic " + Buffer.from("drawbridge:" + key).toString("base64")
640
+ };
641
+ const audiences = [];
642
+ let offset = 0;
643
+ let total = null;
644
+ while (total === null || offset < total && offset < 5e3) {
645
+ const response = await fetcher(
646
+ base(key) + "/lists?count=1000&offset=" + offset + "&fields=lists.id,lists.name,total_items",
647
+ { headers, signal: AbortSignal.timeout(15e3) }
648
+ );
649
+ if (!response.ok) {
650
+ throw Object.assign(
651
+ new Error("Mailchimp refused the request (" + response.status + ")"),
652
+ { status: response.status }
653
+ );
654
+ }
655
+ const body = await response.json();
656
+ for (const list of (body == null ? void 0 : body.lists) || []) {
657
+ audiences.push({ label: (list == null ? void 0 : list.name) || list.id, value: list.id });
658
+ }
659
+ total = (body == null ? void 0 : body.total_items) ?? audiences.length;
660
+ offset = offset + 1e3;
661
+ }
662
+ return audiences;
663
+ }
664
+ },
564
665
  icon: mailchimp_default,
565
666
  // A key with no audience chosen is authenticated and inert. Mailchimp also
566
667
  // needs its merge fields created on that audience before any Drawbridge total
@@ -581,6 +682,7 @@ var mailchimp_default2 = {
581
682
  "auth.disconnect": true,
582
683
  "auth.probe": false,
583
684
  "auth.scopes": false,
685
+ "catalog.audiences": true,
584
686
  "catalog.prices": false,
585
687
  "catalog.products": false,
586
688
  "catalog.promotions": false,
@@ -755,6 +857,7 @@ var shopify_default2 = {
755
857
  // Shopify has no separate price resource — a price belongs to a product
756
858
  // variant and arrives with it, so there is nothing for prices to answer
757
859
  // that products does not already.
860
+ "catalog.audiences": false,
758
861
  "catalog.prices": false,
759
862
  "catalog.products": true,
760
863
  "catalog.promotions": true,
@@ -920,6 +1023,7 @@ var webhook_default = {
920
1023
  "auth.disconnect": true,
921
1024
  "auth.probe": false,
922
1025
  "auth.scopes": false,
1026
+ "catalog.audiences": false,
923
1027
  "catalog.prices": false,
924
1028
  "catalog.products": false,
925
1029
  "catalog.promotions": false,
@@ -971,7 +1075,7 @@ var webhook_default = {
971
1075
  // lib/connections/index.js
972
1076
  var QUEUES = ["connection", "notification", "segment", "webhook"];
973
1077
  var build = (manifest) => {
974
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
1078
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
975
1079
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
976
1080
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
977
1081
  if (!(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
@@ -986,8 +1090,16 @@ var build = (manifest) => {
986
1090
  if (field.input && !INPUTS.includes(field.input)) {
987
1091
  throw new Error(manifest.slug + "." + field.key + " declares an unknown input: " + field.input + " \u2014 one of " + INPUTS.join(", "));
988
1092
  }
989
- if (field.input === "select" && !(field.options || []).length) {
990
- throw new Error(manifest.slug + "." + field.key + " is a select and must declare options");
1093
+ if (field.input === "select" && !(field.options || []).length && !field.source) {
1094
+ throw new Error(manifest.slug + "." + field.key + " is a select and must declare options or a source");
1095
+ }
1096
+ if (field.source) {
1097
+ if (!HOOK_NAMES.includes(field.source)) {
1098
+ throw new Error(manifest.slug + "." + field.key + " sources options from an unknown hook: " + field.source);
1099
+ }
1100
+ if (((_a = manifest.supports) == null ? void 0 : _a[field.source]) !== true) {
1101
+ throw new Error(manifest.slug + "." + field.key + " sources options from " + field.source + ", which it declares unsupported");
1102
+ }
991
1103
  }
992
1104
  }
993
1105
  if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
@@ -1002,15 +1114,15 @@ var build = (manifest) => {
1002
1114
  if (!CATEGORIES.includes(manifest == null ? void 0 : manifest.category)) {
1003
1115
  throw new Error(manifest.slug + " needs a category \u2014 one of " + CATEGORIES.join(", "));
1004
1116
  }
1005
- if ((_a = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _a.type) {
1117
+ if ((_b = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _b.type) {
1006
1118
  throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
1007
1119
  }
1008
- if (!AUTH_TYPES.includes((_b = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _b.type)) {
1120
+ if (!AUTH_TYPES.includes((_c = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _c.type)) {
1009
1121
  throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
1010
1122
  }
1011
1123
  if (manifest.auth.type === "oauth") {
1012
1124
  for (const field of OAUTH_FIELDS) {
1013
- if (!((_c = manifest.auth.oauth) == null ? void 0 : _c[field])) {
1125
+ if (!((_d = manifest.auth.oauth) == null ? void 0 : _d[field])) {
1014
1126
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
1015
1127
  }
1016
1128
  }
@@ -1020,10 +1132,10 @@ var build = (manifest) => {
1020
1132
  );
1021
1133
  }
1022
1134
  }
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)) {
1135
+ if (((_e = manifest.supports) == null ? void 0 : _e["inbound.event"]) && !((_g = (_f = manifest.inbound) == null ? void 0 : _f.headers) == null ? void 0 : _g.event)) {
1024
1136
  throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
1025
1137
  }
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)) {
1138
+ if (((_h = manifest.supports) == null ? void 0 : _h["inbound.verify"]) && !((_j = (_i = manifest.inbound) == null ? void 0 : _i.headers) == null ? void 0 : _j.signature)) {
1027
1139
  throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
1028
1140
  }
1029
1141
  if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
@@ -1039,7 +1151,7 @@ var build = (manifest) => {
1039
1151
  if (typeof hook !== "function") {
1040
1152
  throw new Error(manifest.slug + " declares hook " + name + " but it is not a function");
1041
1153
  }
1042
- if (((_j = manifest.supports) == null ? void 0 : _j[name]) !== true) {
1154
+ if (((_k = manifest.supports) == null ? void 0 : _k[name]) !== true) {
1043
1155
  throw new Error(manifest.slug + " implements " + name + " but declares supports[ '" + name + "' ] false");
1044
1156
  }
1045
1157
  }
@@ -98,6 +98,12 @@ const HOOKS = Object.freeze({
98
98
  // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
99
99
  // and promotion codes, BigCommerce says coupons and promotions.
100
100
  catalog : Object.freeze([
101
+ // The named groups a contact can be synced INTO. Klaviyo calls them lists,
102
+ // Mailchimp calls them audiences; `audiences` is the industry-generic term
103
+ // and belongs to neither vendor's API. Read at form time, so a merchant
104
+ // picks from what actually exists in their account rather than pasting an
105
+ // id from another tab.
106
+ 'audiences',
101
107
  'products',
102
108
  // Shopify folds price into the product variant; Stripe makes Price a
103
109
  // first-class object beside Product. Declared so a vendor that separates
@@ -361,7 +367,17 @@ var klaviyo = {
361
367
  // Google product wants it.
362
368
  auth : {
363
369
  oauth : {
364
- authorize : 'https://a.klaviyo.com/oauth/authorize',
370
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
371
+ //
372
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
373
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
374
+ // never renders a consent screen, so the journey stalls with no error
375
+ // anybody can see.
376
+ //
377
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
378
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
379
+ // image of this mistake breaks the exchange instead of the consent.
380
+ authorize : 'https://www.klaviyo.com/oauth/authorize',
365
381
  // NAMES the env vars holding OUR application's client. One identity,
366
382
  // every merchant — the token is the merchant's and arrives from their
367
383
  // own consent, which is what stops one organization reading another's
@@ -411,6 +427,17 @@ var klaviyo = {
411
427
  {
412
428
  key : 'account',
413
429
  label : 'Klaviyo account'
430
+ },
431
+ {
432
+ input : 'select',
433
+ key : 'list',
434
+ label : 'Klaviyo list',
435
+ message : 'Contacts your campaigns collect are synced into this list.',
436
+ required : true,
437
+ // The choices come from the merchant's own account, not from here — see
438
+ // catalog.audiences below. Static options would mean asking somebody to
439
+ // paste a list id copied out of another browser tab.
440
+ source : 'catalog.audiences'
414
441
  }
415
442
  ],
416
443
  // The three auth hooks, all pure HTTP against Klaviyo — which is why they
@@ -439,6 +466,43 @@ var klaviyo = {
439
466
  //
440
467
  // Basic auth with our client, exactly like the token exchange — the token
441
468
  // being revoked is the subject, not the credential.
469
+ // The lists a merchant can sync into, for the picker on their connection.
470
+ //
471
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
472
+ // so a single call quietly returns the first ten lists and an account with
473
+ // more would show a picker missing the one they wanted — with nothing to
474
+ // indicate anything was cut. Follows links.next, bounded so a runaway
475
+ // cursor cannot spin forever.
476
+ 'catalog.audiences' : async ({ fetcher, token }) => {
477
+
478
+ const audiences = [];
479
+
480
+ let path = '/lists?page%5Bsize%5D=10';
481
+ let pages = 0;
482
+
483
+ while( path && pages < 20 ){
484
+
485
+ const body = await api( path, { fetcher, token });
486
+
487
+ for( const list of ( body?.data || [] ) ){
488
+
489
+ audiences.push({ label : list?.attributes?.name || list.id, value : list.id });
490
+
491
+ }
492
+
493
+ const next = body?.links?.next;
494
+
495
+ // Klaviyo returns an absolute url; the shared caller prefixes its own
496
+ // base, so only the part after /api travels on.
497
+ path = next ? String( next ).replace( /^https:\/\/a\.klaviyo\.com\/api/, '' ) : null;
498
+
499
+ pages = pages + 1;
500
+
501
+ }
502
+
503
+ return audiences;
504
+
505
+ },
442
506
  'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
443
507
 
444
508
  const token = settings?.refreshToken || settings?.accessToken;
@@ -523,6 +587,7 @@ var klaviyo = {
523
587
  'auth.probe' : true,
524
588
  // Klaviyo scopes are fixed at app level and re-consented, not drifted.
525
589
  'auth.scopes' : false,
590
+ 'catalog.audiences' : true,
526
591
  'catalog.prices' : false,
527
592
  'catalog.products' : false,
528
593
  'catalog.promotions' : false,
@@ -559,6 +624,20 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
559
624
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
560
625
  </svg>`;
561
626
 
627
+ // EVERY ACCOUNT LIVES BEHIND A DATA-CENTRE PREFIX, and the key carries it as a
628
+ // suffix — `...-us19` means us19.api.mailchimp.com. There is no fixed host to
629
+ // call, which is Mailchimp's oldest quirk and the reason its future OAuth flow
630
+ // needs a metadata call the other vendors do not.
631
+ const base = ( apiKey ) => {
632
+
633
+ const dc = String( apiKey || '' ).split( '-' ).pop();
634
+
635
+ if( ! dc || dc === apiKey ) throw new Error( 'That Mailchimp key carries no data centre suffix, so there is no host to call' );
636
+
637
+ return 'https://' + dc + '.api.mailchimp.com/3.0';
638
+
639
+ };
640
+
562
641
  // Mailchimp — contact sync, not a sender.
563
642
  //
564
643
  // Deliberately no `group`. Mailchimp and SendGrid shared one while they were
@@ -602,8 +681,70 @@ var mailchimp = {
602
681
  placeholder : '••••••••••••••••••••••••••••••••-us1',
603
682
  redact : true,
604
683
  required : true
684
+ },
685
+ {
686
+ input : 'select',
687
+ key : 'audience',
688
+ label : 'Mailchimp audience',
689
+ message : 'Contacts your campaigns collect are synced into this audience.',
690
+ required : true,
691
+ source : 'catalog.audiences'
605
692
  }
606
693
  ],
694
+ // The audiences a merchant can sync into, for the picker on their connection.
695
+ //
696
+ // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
697
+ // it unset returns the first ten audiences and looks entirely successful —
698
+ // the same silent truncation Klaviyo has, at a different number. Paged
699
+ // against total_items so an account past a thousand still resolves.
700
+ hooks : {
701
+ 'catalog.audiences' : async ({ fetcher = fetch, settings }) => {
702
+
703
+ const key = settings?.apiKey;
704
+
705
+ const headers = {
706
+ // Basic with any username — Mailchimp reads only the password half.
707
+ authorization : 'Basic ' + Buffer.from( 'drawbridge:' + key ).toString( 'base64' )
708
+ };
709
+
710
+ const audiences = [];
711
+
712
+ let offset = 0;
713
+ let total = null;
714
+
715
+ while( total === null || ( offset < total && offset < 5000 ) ){
716
+
717
+ const response = await fetcher(
718
+ base( key ) + '/lists?count=1000&offset=' + offset + '&fields=lists.id,lists.name,total_items',
719
+ { headers, signal : AbortSignal.timeout( 15000 ) }
720
+ );
721
+
722
+ if( ! response.ok ){
723
+
724
+ throw Object.assign(
725
+ new Error( 'Mailchimp refused the request (' + response.status + ')' ),
726
+ { status : response.status }
727
+ );
728
+
729
+ }
730
+
731
+ const body = await response.json();
732
+
733
+ for( const list of ( body?.lists || [] ) ){
734
+
735
+ audiences.push({ label : list?.name || list.id, value : list.id });
736
+
737
+ }
738
+
739
+ total = body?.total_items ?? audiences.length;
740
+ offset = offset + 1000;
741
+
742
+ }
743
+
744
+ return audiences;
745
+
746
+ }
747
+ },
607
748
  icon: icon$2,
608
749
  // A key with no audience chosen is authenticated and inert. Mailchimp also
609
750
  // needs its merge fields created on that audience before any Drawbridge total
@@ -624,6 +765,7 @@ var mailchimp = {
624
765
  'auth.disconnect' : true,
625
766
  'auth.probe' : false,
626
767
  'auth.scopes' : false,
768
+ 'catalog.audiences' : true,
627
769
  'catalog.prices' : false,
628
770
  'catalog.products' : false,
629
771
  'catalog.promotions' : false,
@@ -865,6 +1007,7 @@ var shopify = {
865
1007
  // Shopify has no separate price resource — a price belongs to a product
866
1008
  // variant and arrives with it, so there is nothing for prices to answer
867
1009
  // that products does not already.
1010
+ 'catalog.audiences' : false,
868
1011
  'catalog.prices' : false,
869
1012
  'catalog.products' : true,
870
1013
  'catalog.promotions' : true,
@@ -1037,6 +1180,7 @@ var webhook = {
1037
1180
  'auth.disconnect' : true,
1038
1181
  'auth.probe' : false,
1039
1182
  'auth.scopes' : false,
1183
+ 'catalog.audiences' : false,
1040
1184
  'catalog.prices' : false,
1041
1185
  'catalog.products' : false,
1042
1186
  'catalog.promotions' : false,
@@ -1151,9 +1295,33 @@ const build = ( manifest ) => {
1151
1295
 
1152
1296
  // A choice with nothing to choose from renders an empty dropdown, which
1153
1297
  // looks like a loading state that never resolves.
1154
- if( field.input === 'select' && ! ( field.options || [] ).length ){
1298
+ //
1299
+ // `source` is the other way to have options: the vendor supplies them, per
1300
+ // merchant, at form time. A Klaviyo list cannot be a static list here —
1301
+ // it exists in their account, not in this package — and the alternative is
1302
+ // asking a merchant to paste an id from another browser tab.
1303
+ if( field.input === 'select' && ! ( field.options || [] ).length && ! field.source ){
1304
+
1305
+ throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a source' );
1306
+
1307
+ }
1155
1308
 
1156
- throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options' );
1309
+ // A source names a hook, and the manifest must actually support it
1310
+ // otherwise the form asks for choices from something that will answer
1311
+ // `unsupported`, and the merchant sees a dropdown that never fills.
1312
+ if( field.source ){
1313
+
1314
+ if( ! HOOK_NAMES.includes( field.source ) ){
1315
+
1316
+ throw new Error( manifest.slug + '.' + field.key + ' sources options from an unknown hook: ' + field.source );
1317
+
1318
+ }
1319
+
1320
+ if( manifest.supports?.[ field.source ] !== true ){
1321
+
1322
+ throw new Error( manifest.slug + '.' + field.key + ' sources options from ' + field.source + ', which it declares unsupported' );
1323
+
1324
+ }
1157
1325
 
1158
1326
  }
1159
1327
 
@@ -98,6 +98,12 @@ const HOOKS = Object.freeze({
98
98
  // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
99
99
  // and promotion codes, BigCommerce says coupons and promotions.
100
100
  catalog : Object.freeze([
101
+ // The named groups a contact can be synced INTO. Klaviyo calls them lists,
102
+ // Mailchimp calls them audiences; `audiences` is the industry-generic term
103
+ // and belongs to neither vendor's API. Read at form time, so a merchant
104
+ // picks from what actually exists in their account rather than pasting an
105
+ // id from another tab.
106
+ 'audiences',
101
107
  'products',
102
108
  // Shopify folds price into the product variant; Stripe makes Price a
103
109
  // first-class object beside Product. Declared so a vendor that separates
@@ -361,7 +367,17 @@ var klaviyo = {
361
367
  // Google product wants it.
362
368
  auth : {
363
369
  oauth : {
364
- authorize : 'https://a.klaviyo.com/oauth/authorize',
370
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
371
+ //
372
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
373
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
374
+ // never renders a consent screen, so the journey stalls with no error
375
+ // anybody can see.
376
+ //
377
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
378
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
379
+ // image of this mistake breaks the exchange instead of the consent.
380
+ authorize : 'https://www.klaviyo.com/oauth/authorize',
365
381
  // NAMES the env vars holding OUR application's client. One identity,
366
382
  // every merchant — the token is the merchant's and arrives from their
367
383
  // own consent, which is what stops one organization reading another's
@@ -411,6 +427,17 @@ var klaviyo = {
411
427
  {
412
428
  key : 'account',
413
429
  label : 'Klaviyo account'
430
+ },
431
+ {
432
+ input : 'select',
433
+ key : 'list',
434
+ label : 'Klaviyo list',
435
+ message : 'Contacts your campaigns collect are synced into this list.',
436
+ required : true,
437
+ // The choices come from the merchant's own account, not from here — see
438
+ // catalog.audiences below. Static options would mean asking somebody to
439
+ // paste a list id copied out of another browser tab.
440
+ source : 'catalog.audiences'
414
441
  }
415
442
  ],
416
443
  // The three auth hooks, all pure HTTP against Klaviyo — which is why they
@@ -439,6 +466,43 @@ var klaviyo = {
439
466
  //
440
467
  // Basic auth with our client, exactly like the token exchange — the token
441
468
  // being revoked is the subject, not the credential.
469
+ // The lists a merchant can sync into, for the picker on their connection.
470
+ //
471
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
472
+ // so a single call quietly returns the first ten lists and an account with
473
+ // more would show a picker missing the one they wanted — with nothing to
474
+ // indicate anything was cut. Follows links.next, bounded so a runaway
475
+ // cursor cannot spin forever.
476
+ 'catalog.audiences' : async ({ fetcher, token }) => {
477
+
478
+ const audiences = [];
479
+
480
+ let path = '/lists?page%5Bsize%5D=10';
481
+ let pages = 0;
482
+
483
+ while( path && pages < 20 ){
484
+
485
+ const body = await api( path, { fetcher, token });
486
+
487
+ for( const list of ( body?.data || [] ) ){
488
+
489
+ audiences.push({ label : list?.attributes?.name || list.id, value : list.id });
490
+
491
+ }
492
+
493
+ const next = body?.links?.next;
494
+
495
+ // Klaviyo returns an absolute url; the shared caller prefixes its own
496
+ // base, so only the part after /api travels on.
497
+ path = next ? String( next ).replace( /^https:\/\/a\.klaviyo\.com\/api/, '' ) : null;
498
+
499
+ pages = pages + 1;
500
+
501
+ }
502
+
503
+ return audiences;
504
+
505
+ },
442
506
  'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
443
507
 
444
508
  const token = settings?.refreshToken || settings?.accessToken;
@@ -523,6 +587,7 @@ var klaviyo = {
523
587
  'auth.probe' : true,
524
588
  // Klaviyo scopes are fixed at app level and re-consented, not drifted.
525
589
  'auth.scopes' : false,
590
+ 'catalog.audiences' : true,
526
591
  'catalog.prices' : false,
527
592
  'catalog.products' : false,
528
593
  'catalog.promotions' : false,
@@ -559,6 +624,20 @@ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
559
624
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
560
625
  </svg>`;
561
626
 
627
+ // EVERY ACCOUNT LIVES BEHIND A DATA-CENTRE PREFIX, and the key carries it as a
628
+ // suffix — `...-us19` means us19.api.mailchimp.com. There is no fixed host to
629
+ // call, which is Mailchimp's oldest quirk and the reason its future OAuth flow
630
+ // needs a metadata call the other vendors do not.
631
+ const base = ( apiKey ) => {
632
+
633
+ const dc = String( apiKey || '' ).split( '-' ).pop();
634
+
635
+ if( ! dc || dc === apiKey ) throw new Error( 'That Mailchimp key carries no data centre suffix, so there is no host to call' );
636
+
637
+ return 'https://' + dc + '.api.mailchimp.com/3.0';
638
+
639
+ };
640
+
562
641
  // Mailchimp — contact sync, not a sender.
563
642
  //
564
643
  // Deliberately no `group`. Mailchimp and SendGrid shared one while they were
@@ -602,8 +681,70 @@ var mailchimp = {
602
681
  placeholder : '••••••••••••••••••••••••••••••••-us1',
603
682
  redact : true,
604
683
  required : true
684
+ },
685
+ {
686
+ input : 'select',
687
+ key : 'audience',
688
+ label : 'Mailchimp audience',
689
+ message : 'Contacts your campaigns collect are synced into this audience.',
690
+ required : true,
691
+ source : 'catalog.audiences'
605
692
  }
606
693
  ],
694
+ // The audiences a merchant can sync into, for the picker on their connection.
695
+ //
696
+ // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
697
+ // it unset returns the first ten audiences and looks entirely successful —
698
+ // the same silent truncation Klaviyo has, at a different number. Paged
699
+ // against total_items so an account past a thousand still resolves.
700
+ hooks : {
701
+ 'catalog.audiences' : async ({ fetcher = fetch, settings }) => {
702
+
703
+ const key = settings?.apiKey;
704
+
705
+ const headers = {
706
+ // Basic with any username — Mailchimp reads only the password half.
707
+ authorization : 'Basic ' + Buffer.from( 'drawbridge:' + key ).toString( 'base64' )
708
+ };
709
+
710
+ const audiences = [];
711
+
712
+ let offset = 0;
713
+ let total = null;
714
+
715
+ while( total === null || ( offset < total && offset < 5000 ) ){
716
+
717
+ const response = await fetcher(
718
+ base( key ) + '/lists?count=1000&offset=' + offset + '&fields=lists.id,lists.name,total_items',
719
+ { headers, signal : AbortSignal.timeout( 15000 ) }
720
+ );
721
+
722
+ if( ! response.ok ){
723
+
724
+ throw Object.assign(
725
+ new Error( 'Mailchimp refused the request (' + response.status + ')' ),
726
+ { status : response.status }
727
+ );
728
+
729
+ }
730
+
731
+ const body = await response.json();
732
+
733
+ for( const list of ( body?.lists || [] ) ){
734
+
735
+ audiences.push({ label : list?.name || list.id, value : list.id });
736
+
737
+ }
738
+
739
+ total = body?.total_items ?? audiences.length;
740
+ offset = offset + 1000;
741
+
742
+ }
743
+
744
+ return audiences;
745
+
746
+ }
747
+ },
607
748
  icon: icon$2,
608
749
  // A key with no audience chosen is authenticated and inert. Mailchimp also
609
750
  // needs its merge fields created on that audience before any Drawbridge total
@@ -624,6 +765,7 @@ var mailchimp = {
624
765
  'auth.disconnect' : true,
625
766
  'auth.probe' : false,
626
767
  'auth.scopes' : false,
768
+ 'catalog.audiences' : true,
627
769
  'catalog.prices' : false,
628
770
  'catalog.products' : false,
629
771
  'catalog.promotions' : false,
@@ -865,6 +1007,7 @@ var shopify = {
865
1007
  // Shopify has no separate price resource — a price belongs to a product
866
1008
  // variant and arrives with it, so there is nothing for prices to answer
867
1009
  // that products does not already.
1010
+ 'catalog.audiences' : false,
868
1011
  'catalog.prices' : false,
869
1012
  'catalog.products' : true,
870
1013
  'catalog.promotions' : true,
@@ -1037,6 +1180,7 @@ var webhook = {
1037
1180
  'auth.disconnect' : true,
1038
1181
  'auth.probe' : false,
1039
1182
  'auth.scopes' : false,
1183
+ 'catalog.audiences' : false,
1040
1184
  'catalog.prices' : false,
1041
1185
  'catalog.products' : false,
1042
1186
  'catalog.promotions' : false,
@@ -1151,9 +1295,33 @@ const build = ( manifest ) => {
1151
1295
 
1152
1296
  // A choice with nothing to choose from renders an empty dropdown, which
1153
1297
  // looks like a loading state that never resolves.
1154
- if( field.input === 'select' && ! ( field.options || [] ).length ){
1298
+ //
1299
+ // `source` is the other way to have options: the vendor supplies them, per
1300
+ // merchant, at form time. A Klaviyo list cannot be a static list here —
1301
+ // it exists in their account, not in this package — and the alternative is
1302
+ // asking a merchant to paste an id from another browser tab.
1303
+ if( field.input === 'select' && ! ( field.options || [] ).length && ! field.source ){
1304
+
1305
+ throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options or a source' );
1306
+
1307
+ }
1155
1308
 
1156
- throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options' );
1309
+ // A source names a hook, and the manifest must actually support it
1310
+ // otherwise the form asks for choices from something that will answer
1311
+ // `unsupported`, and the merchant sees a dropdown that never fills.
1312
+ if( field.source ){
1313
+
1314
+ if( ! HOOK_NAMES.includes( field.source ) ){
1315
+
1316
+ throw new Error( manifest.slug + '.' + field.key + ' sources options from an unknown hook: ' + field.source );
1317
+
1318
+ }
1319
+
1320
+ if( manifest.supports?.[ field.source ] !== true ){
1321
+
1322
+ throw new Error( manifest.slug + '.' + field.key + ' sources options from ' + field.source + ', which it declares unsupported' );
1323
+
1324
+ }
1157
1325
 
1158
1326
  }
1159
1327
 
@@ -74,6 +74,12 @@ var HOOKS = Object.freeze({
74
74
  // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
75
75
  // and promotion codes, BigCommerce says coupons and promotions.
76
76
  catalog: Object.freeze([
77
+ // The named groups a contact can be synced INTO. Klaviyo calls them lists,
78
+ // Mailchimp calls them audiences; `audiences` is the industry-generic term
79
+ // and belongs to neither vendor's API. Read at form time, so a merchant
80
+ // picks from what actually exists in their account rather than pasting an
81
+ // id from another tab.
82
+ "audiences",
77
83
  "products",
78
84
  // Shopify folds price into the product variant; Stripe makes Price a
79
85
  // first-class object beside Product. Declared so a vendor that separates
@@ -289,7 +295,17 @@ var klaviyo_default2 = {
289
295
  // Google product wants it.
290
296
  auth: {
291
297
  oauth: {
292
- authorize: "https://a.klaviyo.com/oauth/authorize",
298
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
299
+ //
300
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
301
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
302
+ // never renders a consent screen, so the journey stalls with no error
303
+ // anybody can see.
304
+ //
305
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
306
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
307
+ // image of this mistake breaks the exchange instead of the consent.
308
+ authorize: "https://www.klaviyo.com/oauth/authorize",
293
309
  // NAMES the env vars holding OUR application's client. One identity,
294
310
  // every merchant — the token is the merchant's and arrives from their
295
311
  // own consent, which is what stops one organization reading another's
@@ -339,6 +355,17 @@ var klaviyo_default2 = {
339
355
  {
340
356
  key: "account",
341
357
  label: "Klaviyo account"
358
+ },
359
+ {
360
+ input: "select",
361
+ key: "list",
362
+ label: "Klaviyo list",
363
+ message: "Contacts your campaigns collect are synced into this list.",
364
+ required: true,
365
+ // The choices come from the merchant's own account, not from here — see
366
+ // catalog.audiences below. Static options would mean asking somebody to
367
+ // paste a list id copied out of another browser tab.
368
+ source: "catalog.audiences"
342
369
  }
343
370
  ],
344
371
  // The three auth hooks, all pure HTTP against Klaviyo — which is why they
@@ -364,6 +391,29 @@ var klaviyo_default2 = {
364
391
  //
365
392
  // Basic auth with our client, exactly like the token exchange — the token
366
393
  // being revoked is the subject, not the credential.
394
+ // The lists a merchant can sync into, for the picker on their connection.
395
+ //
396
+ // PAGINATED DELIBERATELY. Klaviyo caps page[size] at 10 and defaults to it,
397
+ // so a single call quietly returns the first ten lists and an account with
398
+ // more would show a picker missing the one they wanted — with nothing to
399
+ // indicate anything was cut. Follows links.next, bounded so a runaway
400
+ // cursor cannot spin forever.
401
+ "catalog.audiences": async ({ fetcher, token }) => {
402
+ var _a, _b;
403
+ const audiences = [];
404
+ let path = "/lists?page%5Bsize%5D=10";
405
+ let pages = 0;
406
+ while (path && pages < 20) {
407
+ const body = await api(path, { fetcher, token });
408
+ for (const list of (body == null ? void 0 : body.data) || []) {
409
+ audiences.push({ label: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id, value: list.id });
410
+ }
411
+ const next = (_b = body == null ? void 0 : body.links) == null ? void 0 : _b.next;
412
+ path = next ? String(next).replace(/^https:\/\/a\.klaviyo\.com\/api/, "") : null;
413
+ pages = pages + 1;
414
+ }
415
+ return audiences;
416
+ },
367
417
  "auth.disconnect": async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
368
418
  const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
369
419
  if (!token) return { revoked: false };
@@ -437,6 +487,7 @@ var klaviyo_default2 = {
437
487
  "auth.probe": true,
438
488
  // Klaviyo scopes are fixed at app level and re-consented, not drifted.
439
489
  "auth.scopes": false,
490
+ "catalog.audiences": true,
440
491
  "catalog.prices": false,
441
492
  "catalog.products": false,
442
493
  "catalog.promotions": false,
@@ -469,6 +520,11 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
469
520
  </svg>`;
470
521
 
471
522
  // lib/connections/mailchimp.js
523
+ var base = (apiKey) => {
524
+ const dc = String(apiKey || "").split("-").pop();
525
+ if (!dc || dc === apiKey) throw new Error("That Mailchimp key carries no data centre suffix, so there is no host to call");
526
+ return "https://" + dc + ".api.mailchimp.com/3.0";
527
+ };
472
528
  var mailchimp_default2 = {
473
529
  // Keys TODAY. Mailchimp integrations authenticate with OAuth 2 (authorization
474
530
  // code) and that is where this goes, so the endpoints are recorded here
@@ -506,8 +562,53 @@ var mailchimp_default2 = {
506
562
  placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022-us1",
507
563
  redact: true,
508
564
  required: true
565
+ },
566
+ {
567
+ input: "select",
568
+ key: "audience",
569
+ label: "Mailchimp audience",
570
+ message: "Contacts your campaigns collect are synced into this audience.",
571
+ required: true,
572
+ source: "catalog.audiences"
509
573
  }
510
574
  ],
575
+ // The audiences a merchant can sync into, for the picker on their connection.
576
+ //
577
+ // count DEFAULTS TO 10 and maxes at 1000 (their own OpenAPI spec), so leaving
578
+ // it unset returns the first ten audiences and looks entirely successful —
579
+ // the same silent truncation Klaviyo has, at a different number. Paged
580
+ // against total_items so an account past a thousand still resolves.
581
+ hooks: {
582
+ "catalog.audiences": async ({ fetcher = fetch, settings }) => {
583
+ const key = settings == null ? void 0 : settings.apiKey;
584
+ const headers = {
585
+ // Basic with any username — Mailchimp reads only the password half.
586
+ authorization: "Basic " + Buffer.from("drawbridge:" + key).toString("base64")
587
+ };
588
+ const audiences = [];
589
+ let offset = 0;
590
+ let total = null;
591
+ while (total === null || offset < total && offset < 5e3) {
592
+ const response = await fetcher(
593
+ base(key) + "/lists?count=1000&offset=" + offset + "&fields=lists.id,lists.name,total_items",
594
+ { headers, signal: AbortSignal.timeout(15e3) }
595
+ );
596
+ if (!response.ok) {
597
+ throw Object.assign(
598
+ new Error("Mailchimp refused the request (" + response.status + ")"),
599
+ { status: response.status }
600
+ );
601
+ }
602
+ const body = await response.json();
603
+ for (const list of (body == null ? void 0 : body.lists) || []) {
604
+ audiences.push({ label: (list == null ? void 0 : list.name) || list.id, value: list.id });
605
+ }
606
+ total = (body == null ? void 0 : body.total_items) ?? audiences.length;
607
+ offset = offset + 1e3;
608
+ }
609
+ return audiences;
610
+ }
611
+ },
511
612
  icon: mailchimp_default,
512
613
  // A key with no audience chosen is authenticated and inert. Mailchimp also
513
614
  // needs its merge fields created on that audience before any Drawbridge total
@@ -528,6 +629,7 @@ var mailchimp_default2 = {
528
629
  "auth.disconnect": true,
529
630
  "auth.probe": false,
530
631
  "auth.scopes": false,
632
+ "catalog.audiences": true,
531
633
  "catalog.prices": false,
532
634
  "catalog.products": false,
533
635
  "catalog.promotions": false,
@@ -702,6 +804,7 @@ var shopify_default2 = {
702
804
  // Shopify has no separate price resource — a price belongs to a product
703
805
  // variant and arrives with it, so there is nothing for prices to answer
704
806
  // that products does not already.
807
+ "catalog.audiences": false,
705
808
  "catalog.prices": false,
706
809
  "catalog.products": true,
707
810
  "catalog.promotions": true,
@@ -867,6 +970,7 @@ var webhook_default = {
867
970
  "auth.disconnect": true,
868
971
  "auth.probe": false,
869
972
  "auth.scopes": false,
973
+ "catalog.audiences": false,
870
974
  "catalog.prices": false,
871
975
  "catalog.products": false,
872
976
  "catalog.promotions": false,
@@ -918,7 +1022,7 @@ var webhook_default = {
918
1022
  // lib/connections/index.js
919
1023
  var QUEUES = ["connection", "notification", "segment", "webhook"];
920
1024
  var build = (manifest) => {
921
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
1025
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
922
1026
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
923
1027
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
924
1028
  if (!(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
@@ -933,8 +1037,16 @@ var build = (manifest) => {
933
1037
  if (field.input && !INPUTS.includes(field.input)) {
934
1038
  throw new Error(manifest.slug + "." + field.key + " declares an unknown input: " + field.input + " \u2014 one of " + INPUTS.join(", "));
935
1039
  }
936
- if (field.input === "select" && !(field.options || []).length) {
937
- throw new Error(manifest.slug + "." + field.key + " is a select and must declare options");
1040
+ if (field.input === "select" && !(field.options || []).length && !field.source) {
1041
+ throw new Error(manifest.slug + "." + field.key + " is a select and must declare options or a source");
1042
+ }
1043
+ if (field.source) {
1044
+ if (!HOOK_NAMES.includes(field.source)) {
1045
+ throw new Error(manifest.slug + "." + field.key + " sources options from an unknown hook: " + field.source);
1046
+ }
1047
+ if (((_a = manifest.supports) == null ? void 0 : _a[field.source]) !== true) {
1048
+ throw new Error(manifest.slug + "." + field.key + " sources options from " + field.source + ", which it declares unsupported");
1049
+ }
938
1050
  }
939
1051
  }
940
1052
  if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
@@ -949,15 +1061,15 @@ var build = (manifest) => {
949
1061
  if (!CATEGORIES.includes(manifest == null ? void 0 : manifest.category)) {
950
1062
  throw new Error(manifest.slug + " needs a category \u2014 one of " + CATEGORIES.join(", "));
951
1063
  }
952
- if ((_a = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _a.type) {
1064
+ if ((_b = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _b.type) {
953
1065
  throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
954
1066
  }
955
- if (!AUTH_TYPES.includes((_b = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _b.type)) {
1067
+ if (!AUTH_TYPES.includes((_c = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _c.type)) {
956
1068
  throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
957
1069
  }
958
1070
  if (manifest.auth.type === "oauth") {
959
1071
  for (const field of OAUTH_FIELDS) {
960
- if (!((_c = manifest.auth.oauth) == null ? void 0 : _c[field])) {
1072
+ if (!((_d = manifest.auth.oauth) == null ? void 0 : _d[field])) {
961
1073
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field);
962
1074
  }
963
1075
  }
@@ -967,10 +1079,10 @@ var build = (manifest) => {
967
1079
  );
968
1080
  }
969
1081
  }
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)) {
1082
+ if (((_e = manifest.supports) == null ? void 0 : _e["inbound.event"]) && !((_g = (_f = manifest.inbound) == null ? void 0 : _f.headers) == null ? void 0 : _g.event)) {
971
1083
  throw new Error(manifest.slug + " supports inbound.event but declares no inbound.headers.event");
972
1084
  }
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)) {
1085
+ if (((_h = manifest.supports) == null ? void 0 : _h["inbound.verify"]) && !((_j = (_i = manifest.inbound) == null ? void 0 : _i.headers) == null ? void 0 : _j.signature)) {
974
1086
  throw new Error(manifest.slug + " supports inbound.verify but declares no inbound.headers.signature");
975
1087
  }
976
1088
  if (typeof (manifest == null ? void 0 : manifest.incomplete) !== "function") {
@@ -986,7 +1098,7 @@ var build = (manifest) => {
986
1098
  if (typeof hook !== "function") {
987
1099
  throw new Error(manifest.slug + " declares hook " + name + " but it is not a function");
988
1100
  }
989
- if (((_j = manifest.supports) == null ? void 0 : _j[name]) !== true) {
1101
+ if (((_k = manifest.supports) == null ? void 0 : _k[name]) !== true) {
990
1102
  throw new Error(manifest.slug + " implements " + name + " but declares supports[ '" + name + "' ] false");
991
1103
  }
992
1104
  }
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.109"
203
+ "version": "0.0.111"
204
204
  }