@drawbridge/drawbridge-utils 0.0.176 → 0.0.178

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.
Files changed (69) hide show
  1. package/dist/admin-B9ZaLvan.d.cts +697 -0
  2. package/dist/admin-C3HtEM6h.d.ts +697 -0
  3. package/dist/billing-Bc4yo9XG.d.cts +175 -0
  4. package/dist/billing-mNsKflmQ.d.ts +175 -0
  5. package/dist/billing.d.cts +1 -1
  6. package/dist/billing.d.ts +1 -1
  7. package/dist/connections/index.cjs +3391 -271
  8. package/dist/connections/index.d.cts +13 -4
  9. package/dist/connections/index.d.ts +13 -4
  10. package/dist/connections/index.js +3388 -270
  11. package/dist/features.cjs +3080 -241
  12. package/dist/features.d.cts +13 -4
  13. package/dist/features.d.ts +13 -4
  14. package/dist/features.js +3120 -275
  15. package/dist/http.cjs +10 -1
  16. package/dist/http.d.cts +10 -1
  17. package/dist/http.d.ts +10 -1
  18. package/dist/http.js +10 -1
  19. package/dist/{index-qY18QITf.d.cts → index-C2rxasGZ.d.cts} +2670 -354
  20. package/dist/{index-B8JhYfvU.d.ts → index-DOYCXtd7.d.ts} +2670 -354
  21. package/dist/oauth/index.d.cts +1 -1
  22. package/dist/oauth/index.d.ts +1 -1
  23. package/dist/oauth-BJDh0sdM.d.cts +527 -0
  24. package/dist/oauth-DveZMLHx.d.ts +527 -0
  25. package/dist/partner-BOZltuh2.d.ts +94 -0
  26. package/dist/partner-ed2OfW1J.d.cts +94 -0
  27. package/dist/plans.cjs +3079 -240
  28. package/dist/plans.d.cts +13 -4
  29. package/dist/plans.d.ts +13 -4
  30. package/dist/plans.js +3120 -275
  31. package/dist/pricing.cjs +3112 -273
  32. package/dist/pricing.d.cts +13 -4
  33. package/dist/pricing.d.ts +13 -4
  34. package/dist/pricing.js +3117 -272
  35. package/dist/providers.cjs +3082 -262
  36. package/dist/providers.d.cts +12 -3
  37. package/dist/providers.d.ts +12 -3
  38. package/dist/providers.js +3086 -260
  39. package/dist/sendgrid.cjs +10 -1
  40. package/dist/sendgrid.js +10 -1
  41. package/dist/shopify/admin.cjs +562 -0
  42. package/dist/shopify/admin.d.cts +3 -0
  43. package/dist/shopify/admin.d.ts +3 -0
  44. package/dist/shopify/admin.js +528 -0
  45. package/dist/shopify/billing.cjs +166 -0
  46. package/dist/shopify/billing.d.cts +3 -0
  47. package/dist/shopify/billing.d.ts +3 -0
  48. package/dist/shopify/billing.js +140 -0
  49. package/dist/shopify/constants.cjs +63 -0
  50. package/dist/shopify/constants.d.cts +58 -0
  51. package/dist/shopify/constants.d.ts +58 -0
  52. package/dist/shopify/constants.js +32 -0
  53. package/dist/shopify/oauth.cjs +509 -0
  54. package/dist/shopify/oauth.d.cts +7 -0
  55. package/dist/shopify/oauth.d.ts +7 -0
  56. package/dist/shopify/oauth.js +466 -0
  57. package/dist/shopify/partner.cjs +156 -0
  58. package/dist/shopify/partner.d.cts +3 -0
  59. package/dist/shopify/partner.d.ts +3 -0
  60. package/dist/shopify/partner.js +130 -0
  61. package/dist/shopify/storefront.cjs +611 -0
  62. package/dist/shopify/storefront.d.cts +3 -0
  63. package/dist/shopify/storefront.d.ts +3 -0
  64. package/dist/shopify/storefront.js +576 -0
  65. package/dist/storefront-C8FKOGeD.d.cts +659 -0
  66. package/dist/storefront-DJFGLqPl.d.ts +659 -0
  67. package/dist/twilio.cjs +10 -1
  68. package/dist/twilio.js +10 -1
  69. package/package.json +98 -68
@@ -1,3 +1,9 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
1
7
  // lib/connections/contract.js
2
8
  var HOOKS = Object.freeze({
3
9
  // Proving and holding the credential.
@@ -124,6 +130,10 @@ var HOOKS = Object.freeze({
124
130
  "code",
125
131
  // Create the buyer at the vendor, so an order can be attributed to them.
126
132
  "customer",
133
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
134
+ // campaign's product list is rendered rather than stored, because stock is
135
+ // the one product fact that is stale the instant it is written down.
136
+ "inventory",
127
137
  // An order arrived at the vendor: attribute it, record it, update totals.
128
138
  "order",
129
139
  // Pull product data across on a vendor update.
@@ -191,7 +201,19 @@ var HOOKS = Object.freeze({
191
201
  organizations: Object.freeze(["add", "list", "remove"]),
192
202
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
193
203
  // declares the plan they are being charged on.
194
- plan: Object.freeze(["cancel", "reselect"]),
204
+ //
205
+ // `verify` is the odd one of the three: not something a merchant does, but
206
+ // the question "is this store's billing actually metered right now". It is
207
+ // here because it is the same subject — and because the answer was being
208
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
209
+ // approval webhook, whose copy said in its own comment that it matched the
210
+ // other and did not.
211
+ // `charge` and `verify` are the two that are not merchant actions: one
212
+ // sends a usage charge to the vendor's meter, the other asks whether that
213
+ // meter is actually working. Both are here because both are the same
214
+ // subject — what the merchant pays the vendor — and both were being done
215
+ // from drawbridge-sync with a direct import of the vendor's client.
216
+ plan: Object.freeze(["cancel", "charge", "reselect", "verify"]),
195
217
  // The embedded app's own session, stored by us on the vendor's behalf.
196
218
  session: Object.freeze(["clear", "read", "save"]),
197
219
  // The one-time code that hands a merchant from the embedded app into
@@ -288,7 +310,7 @@ var answerOf = (answer) => {
288
310
  return effectsOf(answer);
289
311
  };
290
312
  var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
291
- var INSTALL_SIGNATURE = Object.freeze({ options: ["read", "request"], props: ["context"] });
313
+ var INSTALL_SIGNATURE = Object.freeze({ options: ["read"], props: ["context"] });
292
314
  var LIFECYCLE_SIGNATURE = Object.freeze({ options: ["dispatch"], props: ["context"] });
293
315
  var RESOURCE_SIGNATURE = Object.freeze({
294
316
  options: ["fetcher", "shopify"],
@@ -305,15 +327,30 @@ var HOOK_SLOT_PROPS = Object.freeze({
305
327
  // the second bag would be silently discarded, which is exactly the
306
328
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
307
329
  "auth.token": false,
308
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
309
- // api/route/subdomain.js writes nothing, which is why it never became a hook
310
- // so its signature mirrors `code`, its nearest sibling, and moving the
311
- // route here is a deliberate widening of this line rather than a silent one.
312
- "commerce.cart": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
330
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
331
+ // not for an organization inside a workflow, so it takes no `connection` and
332
+ // no `step`. It reads the storefront with the store's own storefront token
333
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
334
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
335
+ // already carries them, because a credential is a fact of the run rather
336
+ // than a service.
337
+ "commerce.cart": Object.freeze({
338
+ options: ["fetcher", "shopify"],
339
+ props: ["clientId", "clientSecret", "context", "settings"]
340
+ }),
313
341
  "commerce.code": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
314
342
  "commerce.customer": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["connection", "context"] }),
343
+ "commerce.inventory": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context"] }),
315
344
  "commerce.order": Object.freeze({ options: ["conversionRate", "logger", "mintId", "read"], props: ["connection", "context"] }),
316
- "commerce.product": Object.freeze({ options: ["mintId", "read"], props: ["connection", "context", "workflow"] }),
345
+ // THREE OPERATIONS, one slot — record, sync, variants. `settings` and
346
+ // `adminToken` arrived with the pull half: the storefront read needs the
347
+ // store's storefront token and the product-feedback send needs its admin one,
348
+ // and both are resolved by the caller because minting either needs a
349
+ // controller.
350
+ "commerce.product": Object.freeze({
351
+ options: ["adminToken", "mintId", "read", "shopify"],
352
+ props: ["connection", "context", "settings", "workflow"]
353
+ }),
317
354
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
318
355
  // person, not the lead document. `remove` called it `email` and `sync` called
319
356
  // it `doc` on one vendor and `lead` on three — three names for two facts.
@@ -326,19 +363,58 @@ var HOOK_SLOT_PROPS = Object.freeze({
326
363
  "inbound.process": Object.freeze({ options: ["dispatch"], props: ["context"] }),
327
364
  "inbound.receive": Object.freeze({ options: [], props: ["channel", "event", "headers", "payload"] }),
328
365
  "inbound.verify": Object.freeze({ options: [], props: ["body", "channel", "headers", "secret", "url"] }),
329
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
330
- // routes in api/route/shopify-embedded.js become, and the signature is
331
- // deliberately narrow: `context` is the request's own body, `read` and
332
- // `request` are the only services an install surface has been shown to need.
333
- // A route that cannot be expressed inside this line is a finding to bring
334
- // back here, not a name to add at the call site.
366
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
367
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
368
+ // of them; the options are where they differ, and every difference below was
369
+ // forced by a line in those routes rather than chosen.
370
+ //
371
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
372
+ // token slots that write, the two clears, and both account slots answer from
373
+ // the shared install record alone.
374
+ //
375
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
376
+ // Drawbridge owns that the manifest must not:
377
+ //
378
+ // conversionRate, planTitle what Drawbridge charges this organization.
379
+ // The org list renders both, and importing the
380
+ // plan catalogue here is the cycle
381
+ // features -> connections -> shopify -> plans
382
+ // -> features that was just broken.
383
+ // currencies which settlement currencies Drawbridge can
384
+ // bill. A store outside them fails the
385
+ // connection validator INSIDE the transaction,
386
+ // which surfaces as an opaque 500, so `add`
387
+ // checks first and says so.
388
+ // mintId the connection's id, known before the write
389
+ // that creates it, because the organization's
390
+ // own refs have to name it in the same
391
+ // transaction.
392
+ // shopify the vendor. Four slots call the store: two
393
+ // read the live plan, two release it.
394
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
395
+ // `reselect` is the only slot with one. See its
396
+ // implementation for why the order is the point.
335
397
  "install.account.link": INSTALL_SIGNATURE,
336
398
  "install.account.unlink": INSTALL_SIGNATURE,
337
- "install.organizations.add": INSTALL_SIGNATURE,
338
- "install.organizations.list": INSTALL_SIGNATURE,
399
+ "install.organizations.add": Object.freeze({
400
+ options: ["currencies", "mintId", "read", "shopify"],
401
+ props: ["context"]
402
+ }),
403
+ "install.organizations.list": Object.freeze({
404
+ options: ["conversionRate", "planTitle", "read", "shopify"],
405
+ props: ["context"]
406
+ }),
339
407
  "install.organizations.remove": INSTALL_SIGNATURE,
340
- "install.plan.cancel": INSTALL_SIGNATURE,
341
- "install.plan.reselect": INSTALL_SIGNATURE,
408
+ "install.plan.cancel": Object.freeze({ options: ["read", "shopify"], props: ["context"] }),
409
+ // `manifest` because the handle this charge bills under is the vendor's own
410
+ // declaration (auth.install.billing.handle), and checking the job against the
411
+ // declaration is what the enqueuer-checks-itself version could not do.
412
+ "install.plan.charge": Object.freeze({
413
+ options: ["shopify"],
414
+ props: ["clientId", "clientSecret", "context", "manifest"]
415
+ }),
416
+ "install.plan.reselect": Object.freeze({ options: ["mark", "read", "shopify"], props: ["context"] }),
417
+ "install.plan.verify": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["context"] }),
342
418
  "install.session.clear": INSTALL_SIGNATURE,
343
419
  "install.session.read": INSTALL_SIGNATURE,
344
420
  "install.session.save": INSTALL_SIGNATURE,
@@ -425,6 +501,16 @@ var TRIGGERS = Object.freeze({
425
501
  "range.ended": Object.freeze({ event: "range.ended", type: "range" }),
426
502
  "range.started": Object.freeze({ event: "range.started", type: "range" }),
427
503
  "schedule.day": Object.freeze({ event: "day", type: "schedule" }),
504
+ // THE THREE SHOPIFY TRIGGERS, which were never in this vocabulary because the
505
+ // four workflows they fire were hand-written in drawbridge-sync's register job
506
+ // rather than provisioned from the manifest. `webhook` is a fourth trigger
507
+ // TYPE, and it is the honest name: these fire when the vendor calls us, which
508
+ // is neither an event Drawbridge raised nor a schedule it keeps.
509
+ "shopify.order.create": Object.freeze({ event: "shopify.order.create", type: "webhook" }),
510
+ "shopify.product.update": Object.freeze({ event: "shopify.product.update", type: "webhook" }),
511
+ // The token audit, dispatched programmatically by the register job when a
512
+ // store's grant is exchanged or rotated.
513
+ "shopify.token": Object.freeze({ event: "shopify.token", type: "event" }),
428
514
  "schedule.month": Object.freeze({ event: "month", type: "schedule" }),
429
515
  "schedule.week": Object.freeze({ event: "week", type: "schedule" }),
430
516
  "segment.contact.add": Object.freeze({ event: "segment.contact.add", type: "event" }),
@@ -477,7 +563,7 @@ var OUTCOMES = Object.freeze({
477
563
  unimplemented: "unimplemented",
478
564
  unsupported: "unsupported"
479
565
  });
480
- var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
566
+ var STATUSES = Object.freeze(["active", "error", "pending"]);
481
567
  var PLATFORM_CREDENTIALS = Object.freeze([
482
568
  // Seals a merchant's stored connection settings. The webhook connection is
483
569
  // gated on it because its whole credential is a secret WE mint and store, so
@@ -534,16 +620,188 @@ var ERROR_SOURCES = Object.freeze({
534
620
  "shopify.register.webhooks": true
535
621
  });
536
622
  var bearing = (entry) => ERROR_SOURCES[entry == null ? void 0 : entry.source] !== false;
537
- var assess = ({ errors = [], status = null } = {}) => {
538
- if (status === "disconnected") return status;
539
- return (errors || []).some(bearing) ? "error" : "active";
540
- };
623
+ var assess = ({ errors = [] } = {}) => (errors || []).some(bearing) ? "error" : "active";
541
624
  var surviving = ({ errors = [], proved = [] } = {}) => (errors || []).filter((entry) => !proved.includes(entry == null ? void 0 : entry.source));
542
625
  var isStatus = (status) => STATUSES.includes(status);
543
626
 
544
627
  // lib/usage.js
545
628
  var isDuplicateKey = (error) => (error == null ? void 0 : error.code) === 11e3 || (error == null ? void 0 : error.code) === "11000" || /E11000|duplicate key/i.test((error == null ? void 0 : error.message) || "");
546
629
 
630
+ // lib/connections/perform.js
631
+ var perform = async ({ answer, controller, emit, enqueue }) => {
632
+ const { enqueues, events, transaction, writes } = answerOf(answer) || {};
633
+ const write = async ({ collection, data, ignoreDuplicate, multiple, operation, options, query }, session) => {
634
+ try {
635
+ await controller[operation]({
636
+ collection,
637
+ data,
638
+ // The controller takes `multiple` beside options — an update that
639
+ // means every matching document must say so explicitly.
640
+ ...multiple && { multiple: true },
641
+ ...(options || session) && { options: { ...options, ...session && { session } } },
642
+ ...query && { query }
643
+ });
644
+ } catch (error) {
645
+ if (!(ignoreDuplicate && isDuplicateKey(error))) throw error;
646
+ }
647
+ };
648
+ if (transaction) {
649
+ await controller.transaction(async (session) => {
650
+ for (const descriptor of writes) await write(descriptor, session);
651
+ });
652
+ } else {
653
+ for (const descriptor of writes) await write(descriptor);
654
+ }
655
+ for (const { data, name, options, queue: target } of enqueues) {
656
+ if (!enqueue) throw new Error("A hook described an enqueue and this caller supplied no enqueuer");
657
+ await enqueue({ data, name, options, queue: target });
658
+ }
659
+ for (const descriptor of events) {
660
+ if (!emit) throw new Error("A hook described a socket event and this caller supplied no emitter");
661
+ emit(descriptor);
662
+ }
663
+ };
664
+
665
+ // lib/encrypt.js
666
+ import crypto2 from "crypto";
667
+
668
+ // lib/token.js
669
+ import crypto from "crypto";
670
+ var generate = (bytes = 32, encoding = "base64url") => {
671
+ const buf = crypto.randomBytes(bytes);
672
+ return encoding ? buf.toString(encoding) : buf;
673
+ };
674
+ var hash = (value, key) => {
675
+ if (!value) return null;
676
+ if (key) {
677
+ return crypto.createHmac("sha256", key).update(String(value)).digest("base64url");
678
+ }
679
+ return crypto.createHash("sha256").update(String(value)).digest("base64url");
680
+ };
681
+
682
+ // lib/encrypt.js
683
+ var ALGORITHM = "aes-256-gcm";
684
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
685
+ var encrypt = (value) => {
686
+ const iv = generate(12, null);
687
+ const cipher = crypto2.createCipheriv(ALGORITHM, getKey(), iv);
688
+ const data = Buffer.concat([
689
+ cipher.update(JSON.stringify(value), "utf8"),
690
+ cipher.final()
691
+ ]);
692
+ const tag = cipher.getAuthTag();
693
+ return [iv, tag, data].map((b) => b.toString("hex")).join(":");
694
+ };
695
+ var decrypt = (value) => {
696
+ if (typeof value !== "string") return value;
697
+ const [ivHex, tagHex, dataHex] = value.split(":");
698
+ const decipher = crypto2.createDecipheriv(
699
+ ALGORITHM,
700
+ getKey(),
701
+ Buffer.from(ivHex, "hex")
702
+ );
703
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
704
+ const result = Buffer.concat([
705
+ decipher.update(Buffer.from(dataHex, "hex")),
706
+ decipher.final()
707
+ ]);
708
+ return JSON.parse(result.toString("utf8"));
709
+ };
710
+
711
+ // lib/connections/reconcile.js
712
+ var connectionSettings = async ({ controller, query }) => {
713
+ const connection = await controller.get({ collection: "connection", query });
714
+ if (!connection) return { connection: null, settings: null };
715
+ return { connection, settings: decrypt(connection.settings) };
716
+ };
717
+ var SCOPES_ERROR_SOURCE = "scopes";
718
+ var SCOPES_ERROR_MESSAGE = "Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.";
719
+ var reconcileConnectionScopes = async ({
720
+ controller,
721
+ enqueue,
722
+ granted = null,
723
+ logger,
724
+ shop,
725
+ slug: slug2 = "shopify"
726
+ }) => {
727
+ var _a, _b, _c;
728
+ if (granted === null) {
729
+ const record = await controller.get({ collection: "shop", query: { shop } });
730
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
731
+ granted = (session == null ? void 0 : session.scope) || null;
732
+ }
733
+ if (!granted) return null;
734
+ const answered = await runHook(slug2, "auth.scopes", { scope: granted });
735
+ const missing2 = answered.outcome === "answered" ? (_a = answered.answer.result) == null ? void 0 : _a.missing : null;
736
+ if (!Array.isArray(missing2)) {
737
+ (_b = logger == null ? void 0 : logger.warn) == null ? void 0 : _b.call(logger, "connection.scopes.unreadable", { shop, slug: slug2 });
738
+ return null;
739
+ }
740
+ const connections2 = await controller.aggregate({
741
+ collection: "connection",
742
+ pipeline: [{ $match: { shop, slug: slug2 } }]
743
+ });
744
+ let hadScopesError = false;
745
+ for (const connection of connections2) {
746
+ const others = (connection.errors || []).filter((entry) => entry.source !== SCOPES_ERROR_SOURCE);
747
+ const hasScopesError = others.length !== (connection.errors || []).length;
748
+ hadScopesError = hadScopesError || hasScopesError;
749
+ if (missing2.length) {
750
+ await controller.update({
751
+ collection: "connection",
752
+ data: {
753
+ $set: {
754
+ errors: [...others, { message: SCOPES_ERROR_MESSAGE, source: SCOPES_ERROR_SOURCE }],
755
+ status: "error"
756
+ }
757
+ },
758
+ query: { id: connection.id }
759
+ });
760
+ } else if (hasScopesError) {
761
+ await controller.update({
762
+ collection: "connection",
763
+ data: {
764
+ $set: {
765
+ errors: others,
766
+ ...connection.status === "error" && !others.length && { status: "active" }
767
+ }
768
+ },
769
+ query: { id: connection.id }
770
+ });
771
+ }
772
+ }
773
+ const products = connections2.length ? await controller.aggregate({
774
+ collection: "product",
775
+ pipeline: [{ $match: { "provider.slug": slug2, "source.domain": shop } }]
776
+ }) : [];
777
+ if (missing2.length && products.length) {
778
+ const productIds = products.map((product) => product.id);
779
+ await controller.update({
780
+ collection: "product",
781
+ data: { $set: { status: "inactive" } },
782
+ multiple: true,
783
+ query: { id: { $in: productIds } }
784
+ });
785
+ await controller.update({
786
+ collection: "advertisement",
787
+ data: { $set: { status: "drafted" } },
788
+ multiple: true,
789
+ query: { product: { $in: productIds } }
790
+ });
791
+ } else if (!missing2.length && hadScopesError && enqueue) {
792
+ for (const product of products) {
793
+ await enqueue({
794
+ data: { product: product.id },
795
+ name: "sync",
796
+ options: { jobId: "product." + slug2 + "." + product.id + "." + Date.now() },
797
+ queue: "product." + slug2
798
+ });
799
+ }
800
+ }
801
+ (_c = logger == null ? void 0 : logger.info) == null ? void 0 : _c.call(logger, "connection.scopes.reconciled", { connections: connections2.length, missing: missing2, shop });
802
+ return missing2;
803
+ };
804
+
547
805
  // lib/connections/workflows.js
548
806
  var systemSteps = (node, path = []) => Object.entries(node || {}).flatMap(
549
807
  ([key, value]) => {
@@ -554,16 +812,28 @@ var systemSteps = (node, path = []) => Object.entries(node || {}).flatMap(
554
812
  return value && typeof value === "object" ? systemSteps(value, [...path, key]) : [];
555
813
  }
556
814
  );
815
+ var systemWorkflows = (manifest) => {
816
+ var _a, _b, _c;
817
+ const grouped = /* @__PURE__ */ new Map();
818
+ for (const [type, declared] of systemSteps(manifest == null ? void 0 : manifest.steps)) {
819
+ const trigger = ((_a = declared.workflow) == null ? void 0 : _a.trigger) || declared.trigger;
820
+ if (!(trigger == null ? void 0 : trigger.event) || !(trigger == null ? void 0 : trigger.type)) continue;
821
+ const title = ((_b = declared.workflow) == null ? void 0 : _b.key) || declared.key;
822
+ if (!title) continue;
823
+ if (!grouped.has(title)) grouped.set(title, { steps: [], title, trigger });
824
+ grouped.get(title).steps.push({ order: ((_c = declared.workflow) == null ? void 0 : _c.order) ?? 0, type });
825
+ }
826
+ return [...grouped.values()].map((workflow) => ({
827
+ ...workflow,
828
+ steps: workflow.steps.slice().sort((a, b) => a.order - b.order)
829
+ }));
830
+ };
557
831
  var ensureSystemWorkflows = async ({ connections: registry = connections, controller, doc }) => {
558
- var _a, _b;
559
- if (!(doc == null ? void 0 : doc.id) || doc.slug === "shopify") return [];
832
+ if (!(doc == null ? void 0 : doc.id)) return [];
560
833
  const manifest = registry[doc.slug];
561
834
  if (!manifest) return [];
562
835
  const provisioned = [];
563
- for (const [type, declared] of systemSteps(manifest.steps)) {
564
- if (!((_a = declared.trigger) == null ? void 0 : _a.event) || !((_b = declared.trigger) == null ? void 0 : _b.type)) continue;
565
- const title = declared.key;
566
- if (!title) continue;
836
+ for (const { steps: members, title, trigger } of systemWorkflows(manifest)) {
567
837
  const existing = await controller.count({
568
838
  collection: "workflow",
569
839
  query: {
@@ -580,13 +850,10 @@ var ensureSystemWorkflows = async ({ connections: registry = connections, contro
580
850
  connection: doc.id,
581
851
  organization: doc.organization,
582
852
  status: "active",
583
- steps: [{
584
- settings: {},
585
- type
586
- }],
853
+ steps: members.map(({ type }) => ({ settings: {}, type })),
587
854
  system: true,
588
855
  title,
589
- trigger: declared.trigger
856
+ trigger
590
857
  };
591
858
  let created;
592
859
  try {
@@ -603,6 +870,135 @@ var ensureSystemWorkflows = async ({ connections: registry = connections, contro
603
870
  return provisioned;
604
871
  };
605
872
 
873
+ // lib/connections/reference.js
874
+ var MARKER = {
875
+ end: "<!-- /generated -->",
876
+ start: "<!-- generated: drawbridge-utils lib/connections/reference.js \u2014 do not edit by hand -->"
877
+ };
878
+ var slugs = () => Object.keys(connections).sort();
879
+ var slots = (node = HOOKS, path = []) => Object.entries(node).flatMap(
880
+ ([key, value]) => Array.isArray(value) ? value.map((verb) => [...path, key, verb].join(".")) : slots(value, [...path, key])
881
+ );
882
+ var implemented = (hooks, name) => {
883
+ const found = name.split(".").reduce(
884
+ (node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
885
+ hooks
886
+ );
887
+ if (typeof found === "function") return "yes";
888
+ if (found && typeof found === "object") return "elsewhere";
889
+ return "no";
890
+ };
891
+ var table = (header, rows) => [
892
+ "| " + header.join(" | ") + " |",
893
+ "|" + header.map(() => "---").join("|") + "|",
894
+ ...rows.map((row2) => "| " + row2.join(" | ") + " |")
895
+ ].join("\n");
896
+ var matrix = () => {
897
+ const columns = slugs();
898
+ return table(
899
+ ["Hook", ...columns],
900
+ slots().map((name) => [
901
+ "`" + name + "`",
902
+ ...columns.map((slug2) => implemented(connections[slug2].hooks, name))
903
+ ])
904
+ );
905
+ };
906
+ var vocabularies = () => {
907
+ const list2 = (values) => values.map((value) => "`" + value + "`").join(", ");
908
+ return table(
909
+ ["Vocabulary", "Values"],
910
+ [
911
+ ["`AUTH_KINDS`", list2(AUTH_KINDS)],
912
+ ["`GROUPS`", list2(GROUPS)],
913
+ ["`INPUTS`", list2(INPUTS)],
914
+ ["`STATUSES`", list2(STATUSES)],
915
+ ["`TRIGGERS`", list2(Object.keys(TRIGGERS).sort())],
916
+ ["`TRIGGER_TYPES`", list2(TRIGGER_TYPES)]
917
+ ]
918
+ );
919
+ };
920
+ var steps = () => {
921
+ const rows = [];
922
+ const walk = (node, path, slug2) => {
923
+ var _a;
924
+ if (!node || typeof node !== "object") return;
925
+ for (const [key, value] of Object.entries(node)) {
926
+ if (typeof value === "function") {
927
+ let declared;
928
+ try {
929
+ declared = value({});
930
+ } catch {
931
+ continue;
932
+ }
933
+ if (!(declared == null ? void 0 : declared.type)) continue;
934
+ const fires = ((_a = declared.workflow) == null ? void 0 : _a.trigger) || declared.trigger;
935
+ rows.push([
936
+ "`" + declared.type + "`",
937
+ slug2,
938
+ declared.hook ? "`" + declared.hook + "`" : "\u2014",
939
+ declared.queue,
940
+ declared.system ? "system" : declared.withdrawn ? "withdrawn" : "builder",
941
+ fires ? "`" + fires.event + "` / `" + fires.type + "`" : (declared.triggers || []).map((t) => "`" + t + "`").join(", ") || "\u2014"
942
+ ]);
943
+ continue;
944
+ }
945
+ walk(value, [...path, key], slug2);
946
+ }
947
+ };
948
+ for (const slug2 of slugs()) walk(connections[slug2].steps, [], slug2);
949
+ return table(["Step", "Connection", "Hook", "Queue", "Shape", "Fired by"], rows.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])));
950
+ };
951
+ var workflows = () => table(
952
+ ["Workflow", "Connection", "Steps", "Trigger"],
953
+ slugs().flatMap((slug2) => systemWorkflows(connections[slug2]).map((workflow) => [
954
+ workflow.title,
955
+ slug2,
956
+ workflow.steps.map((step) => "`" + step.type + "`").join(" \u2192 "),
957
+ "`" + workflow.trigger.event + "` / `" + workflow.trigger.type + "`"
958
+ ]))
959
+ );
960
+ var reference = () => [
961
+ MARKER.start,
962
+ "",
963
+ "### The support matrix",
964
+ "",
965
+ "`yes` is a body in this package. `elsewhere` is declared here and implemented in the repo holding the dependencies \u2014 a different fact from `no`, and collapsing the two is how a table tells a reader the package can do something it cannot.",
966
+ "",
967
+ matrix(),
968
+ "",
969
+ "### The closed vocabularies",
970
+ "",
971
+ vocabularies(),
972
+ "",
973
+ "### Every step",
974
+ "",
975
+ "A step with no hook is real: Shopify's token-audit pair is declared so a stray dispatch lands on a real queue, and is never fired.",
976
+ "",
977
+ steps(),
978
+ "",
979
+ "### The system workflows a connection is provisioned with",
980
+ "",
981
+ "Idempotent on (connection, system, title), so the TITLE is load-bearing \u2014 one that varied by account would provision a second workflow every time it changed.",
982
+ "",
983
+ workflows(),
984
+ "",
985
+ "### Counts",
986
+ "",
987
+ "- **" + slugs().length + "** connections",
988
+ "- **" + slots().length + "** hook slots",
989
+ "- **" + Object.keys(STEPS).length + "** step types",
990
+ "- **" + Object.keys(TRIGGERS).length + "** triggers",
991
+ "",
992
+ MARKER.end
993
+ ].join("\n");
994
+ var spliced = (document) => {
995
+ const from = document.indexOf(MARKER.start);
996
+ const to = document.indexOf(MARKER.end);
997
+ if (from < 0 || to < 0) return document;
998
+ return document.slice(0, from) + reference() + document.slice(to + MARKER.end.length);
999
+ };
1000
+ var MARKERS = MARKER;
1001
+
606
1002
  // lib/connections/oauth.js
607
1003
  import { createHash, randomBytes } from "crypto";
608
1004
  var credentials = ({ basic, clientId, clientSecret }) => basic ? {
@@ -657,10 +1053,10 @@ var authToken = async ({
657
1053
  const renewing = !code;
658
1054
  if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
659
1055
  if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
660
- const client = credentials({ basic, clientId, clientSecret });
1056
+ const client2 = credentials({ basic, clientId, clientSecret });
661
1057
  const response = await fetcher(descriptor.endpoints.token, {
662
1058
  body: new URLSearchParams({
663
- ...client.body,
1059
+ ...client2.body,
664
1060
  ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
665
1061
  code: decodeURIComponent(String(code || "").trim()),
666
1062
  grant_type: "authorization_code",
@@ -668,7 +1064,7 @@ var authToken = async ({
668
1064
  ...descriptor.pkce && { code_verifier: verifier }
669
1065
  }
670
1066
  }),
671
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
1067
+ headers: { "content-type": "application/x-www-form-urlencoded", ...client2.headers },
672
1068
  method: "POST",
673
1069
  signal: AbortSignal.timeout(15e3)
674
1070
  });
@@ -1124,9 +1520,18 @@ var scaffold_default = {
1124
1520
  commerce: {
1125
1521
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
1126
1522
  // the last api surface Shopify owns outside a hook.
1127
- cart: async ({ connection, context }, { read } = {}) => ANSWER,
1523
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
1524
+ // page, not for an organization inside a workflow. It reads the storefront
1525
+ // with the store's storefront token and mints a checkout token from
1526
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
1527
+ // as props — the way auth.disconnect already carries it.
1528
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify } = {}) => ANSWER,
1128
1529
  code: async ({ connection, context, step }, { adminToken, shopify } = {}) => ANSWER,
1129
1530
  customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => ANSWER,
1531
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
1532
+ // fact that is stale the instant it is written down, so the campaign
1533
+ // product list asks the vendor and caches the answer for a minute.
1534
+ inventory: async ({ connection, context }, { adminToken, shopify } = {}) => ANSWER,
1130
1535
  order: async ({ connection, context }, { logger, mintId, read } = {}) => ANSWER,
1131
1536
  // THIS IS WHERE `product.insert` LANDS.
1132
1537
  //
@@ -1148,7 +1553,7 @@ var scaffold_default = {
1148
1553
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
1149
1554
  // a log. Phase 1 closes it and build() checks every trigger against it.
1150
1555
  product: {
1151
- hook: async ({ connection, context, workflow }, { mintId, read } = {}) => ANSWER,
1556
+ hook: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify } = {}) => ANSWER,
1152
1557
  step: () => ({
1153
1558
  description: "Syncs product data from the vendor when a product changes.",
1154
1559
  key: "Vendor Product Sync",
@@ -1296,8 +1701,19 @@ var scaffold_default = {
1296
1701
  // Vendor billing, the only group that is not generic. Pairs with
1297
1702
  // auth.install.billing, which declares what the merchant is being charged.
1298
1703
  plan: {
1299
- cancel: async ({ context }, { dispatch, read } = {}) => ANSWER,
1300
- reselect: async ({ context }, { dispatch, read } = {}) => ANSWER
1704
+ cancel: async ({ context }, { read, shopify } = {}) => ANSWER,
1705
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
1706
+ // sent to the vendor's meter. The credential pair rides in as props
1707
+ // because the charge authenticates as the APP, not as the store.
1708
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify } = {}) => ANSWER,
1709
+ // `mark` is the one write a description cannot carry: the marker must
1710
+ // be persisted BEFORE the approval is released, and a described write
1711
+ // lands after the hook returns.
1712
+ reselect: async ({ context }, { mark, read, shopify } = {}) => ANSWER,
1713
+ // NOT A MERCHANT ACTION — the question "is this store's billing
1714
+ // actually metered right now", asked by the daily health check and by
1715
+ // the vendor's own approval webhook, which used to hand-copy it.
1716
+ verify: async ({ context }, { adminToken, read, shopify } = {}) => ANSWER
1301
1717
  },
1302
1718
  // The embedded app's own session, stored by us on the vendor's behalf.
1303
1719
  session: {
@@ -1783,16 +2199,19 @@ var attentive_default2 = {
1783
2199
  "Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
1784
2200
  "Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs."
1785
2201
  ],
1786
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
1787
- // The dashboard already carries generic reasons for every way an
1788
- // authorization can fail — declined, expired state, a vendor we are not set
1789
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
1790
- // failures need saying differently. This one's do not.
2202
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
2203
+ // consent screen has two ways to end: they said no, or something between
2204
+ // here and the vendor went wrong.
1791
2205
  //
1792
- // Answered rather than omitted, because `errors` existed on two of six
1793
- // manifests and there was no way to tell "nothing to add" from "nobody
1794
- // wrote it".
1795
- errors: false,
2206
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
2207
+ // authorization"; a merchant connecting two accounts in one sitting needs
2208
+ // to know which one this was.
2209
+ errors: {
2210
+ connect: {
2211
+ denied: "The Attentive authorization was declined, so nothing was connected.",
2212
+ invalid: "We couldn't complete the Attentive connection. Try connecting again."
2213
+ }
2214
+ },
1796
2215
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
1797
2216
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
1798
2217
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -2320,6 +2739,15 @@ var readEventHeader = ({ descriptor, headers }) => ({ result: { event: headers[d
2320
2739
  var DEFAULT_TIMEOUT_MS = 15e3;
2321
2740
  var request = async ({
2322
2741
  body,
2742
+ // THE TRANSPORT, injectable and defaulted to the real one.
2743
+ //
2744
+ // Every other vendor client in this package takes a `fetcher` — it is how
2745
+ // klaviyo, mailchimp and attentive are tested without a network, and it is a
2746
+ // declared HOOK_OPTION. The Shopify client had no seam at all, so the only way
2747
+ // to test a hook that used it was to inject the whole SDK namespace; that
2748
+ // injection existed to work around an import cycle, and when the cycle went
2749
+ // the tests lost their only hold. This is the seam they should have had.
2750
+ fetcher = fetch,
2323
2751
  headers = {},
2324
2752
  method = "GET",
2325
2753
  query,
@@ -2333,7 +2761,7 @@ var request = async ({
2333
2761
  }
2334
2762
  ;
2335
2763
  const isForm = type === "form";
2336
- const response = await fetch(fullUrl.toString(), {
2764
+ const response = await fetcher(fullUrl.toString(), {
2337
2765
  method,
2338
2766
  headers: {
2339
2767
  "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
@@ -2790,15 +3218,12 @@ var drawbridge_default2 = {
2790
3218
  description: [
2791
3219
  "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2792
3220
  ],
2793
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2794
- // The dashboard already carries generic reasons for every way an
2795
- // authorization can fail declined, expired state, a vendor we are not set
2796
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2797
- // failures need saying differently. This one's do not.
3221
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
3222
+ // own manifest always on, private, never in the catalogue — and it exists
3223
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
3224
+ // describe a consent screen this connection does not have.
2798
3225
  //
2799
- // Answered rather than omitted, because `errors` existed on two of six
2800
- // manifests and there was no way to tell "nothing to add" from "nobody
2801
- // wrote it".
3226
+ // `false` is the recorded decision, not silence.
2802
3227
  errors: false,
2803
3228
  excerpt: "The steps Drawbridge runs itself.",
2804
3229
  guide: [
@@ -3558,10 +3983,10 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3558
3983
 
3559
3984
  // lib/connections/scopes.js
3560
3985
  var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3561
- var missingScopes = ({ granted, required }) => {
3986
+ var missingScopes = ({ granted, required: required2 }) => {
3562
3987
  if (granted === null || granted === void 0 || granted === "") return null;
3563
3988
  const held = new Set(list(granted));
3564
- return list(required).filter((scope) => !held.has(scope));
3989
+ return list(required2).filter((scope) => !held.has(scope));
3565
3990
  };
3566
3991
 
3567
3992
  // lib/connections/manifests/klaviyo.js
@@ -4460,16 +4885,19 @@ var mailchimp_default2 = {
4460
4885
  "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
4461
4886
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
4462
4887
  ],
4463
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4464
- // The dashboard already carries generic reasons for every way an
4465
- // authorization can fail — declined, expired state, a vendor we are not set
4466
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4467
- // failures need saying differently. This one's do not.
4888
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
4889
+ // consent screen has two ways to end: they said no, or something between
4890
+ // here and the vendor went wrong.
4468
4891
  //
4469
- // Answered rather than omitted, because `errors` existed on two of six
4470
- // manifests and there was no way to tell "nothing to add" from "nobody
4471
- // wrote it".
4472
- errors: false,
4892
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
4893
+ // authorization"; a merchant connecting two accounts in one sitting needs
4894
+ // to know which one this was.
4895
+ errors: {
4896
+ connect: {
4897
+ denied: "The Mailchimp authorization was declined, so nothing was connected.",
4898
+ invalid: "We couldn't complete the Mailchimp connection. Try connecting again."
4899
+ }
4900
+ },
4473
4901
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
4474
4902
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
4475
4903
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -4568,7 +4996,7 @@ var mailchimp_default2 = {
4568
4996
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4569
4997
  const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
4570
4998
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4571
- const hash = subscriberHash(email);
4999
+ const hash2 = subscriberHash(email);
4572
5000
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4573
5001
  const lastName = restOfName.join(" ");
4574
5002
  const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
@@ -4577,7 +5005,7 @@ var mailchimp_default2 = {
4577
5005
  ...lastName && { LNAME: lastName },
4578
5006
  ...phone && { PHONE: phone }
4579
5007
  };
4580
- const member = await api3("/lists/" + audience + "/members/" + hash, {
5008
+ const member = await api3("/lists/" + audience + "/members/" + hash2, {
4581
5009
  dc: settings == null ? void 0 : settings.dc,
4582
5010
  fetcher,
4583
5011
  method: "PUT",
@@ -4618,7 +5046,7 @@ var mailchimp_default2 = {
4618
5046
  status: joined.has(entry.id) ? "active" : "inactive"
4619
5047
  }));
4620
5048
  if (tags.length > 0) {
4621
- await api3("/lists/" + audience + "/members/" + hash + "/tags", {
5049
+ await api3("/lists/" + audience + "/members/" + hash2 + "/tags", {
4622
5050
  dc: settings == null ? void 0 : settings.dc,
4623
5051
  fetcher,
4624
5052
  method: "POST",
@@ -4629,12 +5057,12 @@ var mailchimp_default2 = {
4629
5057
  }
4630
5058
  return {
4631
5059
  // Merged into `context` for later steps in this run.
4632
- context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
5060
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 },
4633
5061
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4634
5062
  request: request2,
4635
5063
  // Recorded on the run for support to read back, not a write
4636
5064
  // instruction — the hook has already written what it needed to.
4637
- response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
5065
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 }
4638
5066
  };
4639
5067
  }
4640
5068
  },
@@ -5004,139 +5432,2030 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
5004
5432
  <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
5005
5433
  </svg>`;
5006
5434
 
5007
- // lib/email.js
5008
- var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
5009
- var toCanonicalEmail = (value) => {
5010
- if (!value || typeof value !== "string") return null;
5011
- const email = value.trim().toLowerCase();
5012
- const at = email.lastIndexOf("@");
5013
- if (at < 1 || at === email.length - 1) return null;
5014
- let local = email.slice(0, at);
5015
- const domain = email.slice(at + 1);
5016
- const plus = local.indexOf("+");
5017
- if (plus > 0) local = local.slice(0, plus);
5018
- if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
5019
- if (!local) return null;
5020
- return local + "@" + domain;
5021
- };
5435
+ // lib/shopify/admin.js
5436
+ var admin_exports = {};
5437
+ __export(admin_exports, {
5438
+ adminFetch: () => adminFetch,
5439
+ createDiscountCode: () => createDiscountCode,
5440
+ createUsageRecord: () => createUsageRecord,
5441
+ createUsageSubscription: () => createUsageSubscription,
5442
+ getActiveAppSubscriptions: () => getActiveAppSubscriptions,
5443
+ getDiscounts: () => getDiscounts,
5444
+ getOrCreateCustomer: () => getOrCreateCustomer,
5445
+ getProductInventory: () => getProductInventory,
5446
+ sendProductResourceFeedback: () => sendProductResourceFeedback
5447
+ });
5022
5448
 
5023
- // lib/encrypt.js
5024
- import crypto from "crypto";
5025
- var ALGORITHM = "aes-256-gcm";
5026
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
5027
- var decrypt = (value) => {
5028
- if (typeof value !== "string") return value;
5029
- const [ivHex, tagHex, dataHex] = value.split(":");
5030
- const decipher = crypto.createDecipheriv(
5031
- ALGORITHM,
5032
- getKey(),
5033
- Buffer.from(ivHex, "hex")
5034
- );
5035
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
5036
- const result = Buffer.concat([
5037
- decipher.update(Buffer.from(dataHex, "hex")),
5038
- decipher.final()
5039
- ]);
5040
- return JSON.parse(result.toString("utf8"));
5041
- };
5449
+ // lib/shopify/constants.js
5450
+ var SHOPIFY_STOREFRONT_API_VERSION = "2025-01";
5451
+ var SHOPIFY_ADMIN_API_VERSION = "2026-04";
5452
+ var SHOPIFY_APP_API_VERSION = "unstable";
5453
+ var SHOPIFY_REQUIRED_SCOPES = [
5454
+ "read_customers",
5455
+ "write_customers",
5456
+ "read_discounts",
5457
+ "write_discounts",
5458
+ "read_orders",
5459
+ "read_products",
5460
+ "read_publications",
5461
+ "write_resource_feedbacks",
5462
+ "unauthenticated_read_checkouts",
5463
+ "unauthenticated_read_product_inventory",
5464
+ "unauthenticated_read_product_listings",
5465
+ "unauthenticated_write_checkouts"
5466
+ ];
5467
+ var REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1e3;
5468
+ var ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
5469
+ var PARTNER_API_VERSION = process.env.SHOPIFY_PARTNER_API_VERSION || "2026-07";
5042
5470
 
5043
- // lib/slugify.js
5044
- import slug from "slugify";
5045
- var slugify = (value, nochars = false) => {
5046
- const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
5047
- const replacement = nochars ? "" : "-";
5048
- return slug(
5049
- value,
5050
- {
5051
- remove,
5052
- replacement,
5053
- lower: true,
5054
- trim: true
5471
+ // lib/shopify/admin.js
5472
+ var adminUrl = (domain) => `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}`;
5473
+ var adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
5474
+ var _a;
5475
+ const { data, errors } = await request({
5476
+ fetcher,
5477
+ method: "POST",
5478
+ url: adminUrl(domain) + "/graphql.json",
5479
+ headers: {
5480
+ "X-Shopify-Access-Token": adminAccessToken
5481
+ },
5482
+ body: {
5483
+ query,
5484
+ ...variables && { variables }
5055
5485
  }
5056
- );
5486
+ });
5487
+ if (errors) {
5488
+ throw new Error("Shopify GraphQL error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
5489
+ }
5490
+ ;
5491
+ return data;
5057
5492
  };
5058
-
5059
- // lib/connections/manifests/shopify.js
5060
- var toLine = ({
5061
- price,
5062
- product_id: productId,
5063
- quantity,
5064
- title,
5065
- variant_id: variantId,
5066
- variant_title: variantTitle
5067
- }) => ({
5068
- price: parseFloat(price) || 0,
5069
- productId: productId ? "gid://shopify/Product/" + productId : null,
5070
- quantity: quantity || 1,
5071
- title: title || null,
5072
- variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
5073
- variantTitle: variantTitle || null
5074
- });
5075
- var attributeLineItems = (lineItems = []) => lineItems.reduce(
5076
- (acc, item) => {
5077
- const attrs = (item.properties || []).reduce(
5078
- (map, { name, value }) => {
5079
- map[name] = value;
5080
- return map;
5081
- },
5082
- {}
5083
- );
5084
- if (!attrs["_drwbrdg_ca"]) return acc;
5085
- if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
5086
- const line = toLine(item);
5087
- acc.attributedGross += line.price * line.quantity;
5088
- acc.attributedLines.push(line);
5089
- return acc;
5090
- },
5091
- { attrMap: {}, attributedGross: 0, attributedLines: [] }
5092
- );
5093
- var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
5094
- var blockedReason = (discount) => {
5095
- var _a;
5096
- if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
5097
- const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
5098
- if (buyers && buyers !== "DiscountBuyerSelectionAll") {
5099
- return "This discount is limited to specific buyers in Shopify, so a code issued to an entrant won't work. Set it to all customers to use it here.";
5493
+ var DISCOUNT_FIELDS = `
5494
+ title
5495
+ status
5496
+ startsAt
5497
+ endsAt
5498
+ usageLimit
5499
+ asyncUsageCount
5500
+ appliesOncePerCustomer
5501
+ context { __typename }
5502
+ `;
5503
+ var discountsQuery = (search, cursor, limit) => `{
5504
+ codeDiscountNodes(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}) {
5505
+ edges {
5506
+ node {
5507
+ id
5508
+ codeDiscount {
5509
+ ... on DiscountCodeBasic { ${DISCOUNT_FIELDS} }
5510
+ ... on DiscountCodeBxgy { ${DISCOUNT_FIELDS} }
5511
+ ... on DiscountCodeFreeShipping { ${DISCOUNT_FIELDS} }
5512
+ ... on DiscountCodeApp { ${DISCOUNT_FIELDS} }
5513
+ }
5514
+ }
5515
+ }
5516
+ pageInfo {
5517
+ endCursor
5518
+ hasNextPage
5519
+ hasPreviousPage
5520
+ startCursor
5521
+ }
5522
+ }
5523
+ }`;
5524
+ var getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
5525
+ const data = await adminFetch({
5526
+ fetcher,
5527
+ adminAccessToken,
5528
+ domain,
5529
+ query: discountsQuery(search, cursor, limit)
5530
+ });
5531
+ return data == null ? void 0 : data.codeDiscountNodes;
5532
+ };
5533
+ var discountGid = (id) => String(id).startsWith("gid://") ? String(id) : "gid://shopify/DiscountCodeNode/" + id;
5534
+ var createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code }) => {
5535
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5536
+ const added = await adminFetch({
5537
+ fetcher,
5538
+ adminAccessToken,
5539
+ domain,
5540
+ query: `
5541
+ mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
5542
+ discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
5543
+ bulkCreation {
5544
+ id
5545
+ }
5546
+ userErrors {
5547
+ field
5548
+ message
5549
+ }
5550
+ }
5551
+ }
5552
+ `,
5553
+ variables: {
5554
+ codes: [{ code }],
5555
+ discountId: discountGid(discountId)
5556
+ }
5557
+ });
5558
+ const addErrors = (_a = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _a.userErrors;
5559
+ if (addErrors == null ? void 0 : addErrors.length) {
5560
+ throw new Error("Shopify discount code create failed: " + addErrors[0].message);
5100
5561
  }
5101
5562
  ;
5102
- if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
5103
- return "This discount has reached its total usage limit.";
5563
+ const bulkCreationId = (_c = (_b = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _b.bulkCreation) == null ? void 0 : _c.id;
5564
+ if (!bulkCreationId) {
5565
+ throw new Error("Shopify discount code create failed: no bulk creation id returned");
5104
5566
  }
5105
5567
  ;
5106
- return null;
5107
- };
5108
- var discountWarning = (discount) => {
5109
- if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
5110
- return "This discount hasn't started yet, so codes issued before it does won't work until then.";
5568
+ for (let attempt = 0; attempt < 15; attempt++) {
5569
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5570
+ const polled = await adminFetch({
5571
+ fetcher,
5572
+ adminAccessToken,
5573
+ domain,
5574
+ query: `
5575
+ query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
5576
+ discountRedeemCodeBulkCreation(id: $id) {
5577
+ done
5578
+ codes(first: 1) {
5579
+ nodes {
5580
+ code
5581
+ discountRedeemCode {
5582
+ id
5583
+ }
5584
+ errors {
5585
+ message
5586
+ }
5587
+ }
5588
+ }
5589
+ }
5590
+ }
5591
+ `,
5592
+ variables: {
5593
+ id: bulkCreationId
5594
+ }
5595
+ });
5596
+ const creation = polled == null ? void 0 : polled.discountRedeemCodeBulkCreation;
5597
+ if (!(creation == null ? void 0 : creation.done)) continue;
5598
+ const node = (_e = (_d = creation.codes) == null ? void 0 : _d.nodes) == null ? void 0 : _e[0];
5599
+ if (!((_f = node == null ? void 0 : node.discountRedeemCode) == null ? void 0 : _f.id)) {
5600
+ throw new Error("Shopify discount code create failed: " + (((_h = (_g = node == null ? void 0 : node.errors) == null ? void 0 : _g[0]) == null ? void 0 : _h.message) || "code was not created"));
5601
+ }
5602
+ ;
5603
+ return {
5604
+ code: node.code,
5605
+ // Numeric tail keeps the stored shopifyDiscountId in the shape the
5606
+ // legacy REST endpoint returned — it feeds a merchant-visible
5607
+ // workflow template variable, so no gid:// leakage.
5608
+ id: node.discountRedeemCode.id.split("/").pop()
5609
+ };
5111
5610
  }
5112
5611
  ;
5113
- if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
5114
- return null;
5612
+ throw new Error("Shopify discount code create timed out waiting for bulk creation " + bulkCreationId);
5115
5613
  };
5116
- var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
5117
- var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
5118
- var OAUTH_ERROR_SOURCE = "oauth";
5119
- var BILLING_ERROR_SOURCE = "billing";
5120
- var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
5121
- var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
5122
- var inbound = {
5123
- headers: {
5124
- event: "x-shopify-topic",
5125
- id: "x-shopify-webhook-id",
5126
- shop: "x-shopify-shop-domain",
5127
- signature: "x-shopify-hmac-sha256"
5128
- },
5129
- signature: {
5130
- algorithm: "sha256",
5131
- encoding: "base64",
5132
- secret: "SHOPIFY_API_SECRET"
5614
+ var getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
5615
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5616
+ const lookupQuery = `
5617
+ query customerByEmail( $query : String! ) {
5618
+ customers( first : 1, query : $query ) {
5619
+ edges {
5620
+ node {
5621
+ id
5622
+ }
5623
+ }
5624
+ }
5625
+ }
5626
+ `;
5627
+ const data = await adminFetch({
5628
+ fetcher,
5629
+ adminAccessToken,
5630
+ domain,
5631
+ query: lookupQuery,
5632
+ variables: { query: "email:" + email }
5633
+ });
5634
+ const existing = (_c = (_b = (_a = data == null ? void 0 : data.customers) == null ? void 0 : _a.edges) == null ? void 0 : _b[0]) == null ? void 0 : _c.node;
5635
+ if (existing) return existing;
5636
+ const input = { email };
5637
+ if (firstName) input.firstName = firstName;
5638
+ if (lastName) input.lastName = lastName;
5639
+ if (source) {
5640
+ input.metafields = [{
5641
+ namespace: "drwbrdg",
5642
+ key: "source",
5643
+ type: "single_line_text_field",
5644
+ value: source
5645
+ }];
5133
5646
  }
5134
- };
5647
+ ;
5648
+ const result = await adminFetch({
5649
+ fetcher,
5650
+ adminAccessToken,
5651
+ domain,
5652
+ query: `
5653
+ mutation customerCreate( $input : CustomerInput! ) {
5654
+ customerCreate( input : $input ) {
5655
+ customer {
5656
+ id
5657
+ }
5658
+ userErrors {
5659
+ field
5660
+ message
5661
+ }
5662
+ }
5663
+ }
5664
+ `,
5665
+ variables: { input }
5666
+ });
5667
+ const userErrors = (_d = result == null ? void 0 : result.customerCreate) == null ? void 0 : _d.userErrors;
5668
+ if (userErrors == null ? void 0 : userErrors.length) {
5669
+ const emailTaken = userErrors.some((e) => {
5670
+ var _a2;
5671
+ return (_a2 = e.message) == null ? void 0 : _a2.includes("Email has already been taken");
5672
+ });
5673
+ if (emailTaken) {
5674
+ const retry = await adminFetch({
5675
+ fetcher,
5676
+ adminAccessToken,
5677
+ domain,
5678
+ query: lookupQuery,
5679
+ variables: { query: "email:" + email }
5680
+ });
5681
+ const found = (_g = (_f = (_e = retry == null ? void 0 : retry.customers) == null ? void 0 : _e.edges) == null ? void 0 : _f[0]) == null ? void 0 : _g.node;
5682
+ if (found) return found;
5683
+ }
5684
+ ;
5685
+ throw new Error("Shopify customer create failed: " + userErrors[0].message);
5686
+ }
5687
+ ;
5688
+ return (_h = result == null ? void 0 : result.customerCreate) == null ? void 0 : _h.customer;
5689
+ };
5690
+ var getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
5691
+ var _a, _b;
5692
+ const data = await adminFetch({
5693
+ fetcher,
5694
+ adminAccessToken,
5695
+ domain,
5696
+ query: `{
5697
+ currentAppInstallation {
5698
+ activeSubscriptions {
5699
+ id
5700
+ name
5701
+ status
5702
+ test
5703
+ lineItems {
5704
+ id
5705
+ plan {
5706
+ pricingDetails {
5707
+ __typename
5708
+ }
5709
+ }
5710
+ }
5711
+ }
5712
+ }
5713
+ shop {
5714
+ myshopifyDomain
5715
+ }
5716
+ }`
5717
+ });
5718
+ return {
5719
+ shopDomain: ((_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.myshopifyDomain) || null,
5720
+ subscriptions: (((_b = data == null ? void 0 : data.currentAppInstallation) == null ? void 0 : _b.activeSubscriptions) || []).map((subscription) => {
5721
+ var _a2;
5722
+ return {
5723
+ ...subscription,
5724
+ metered: ((subscription == null ? void 0 : subscription.lineItems) || []).some(
5725
+ (item) => {
5726
+ var _a3, _b2;
5727
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5728
+ }
5729
+ ),
5730
+ // The usage line's own id — the address appUsageRecordCreate charges
5731
+ // against. Null when the approval carries no usage component.
5732
+ usageLineItemId: ((_a2 = ((subscription == null ? void 0 : subscription.lineItems) || []).find(
5733
+ (item) => {
5734
+ var _a3, _b2;
5735
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5736
+ }
5737
+ )) == null ? void 0 : _a2.id) || null
5738
+ };
5739
+ })
5740
+ };
5741
+ };
5742
+ var createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = "USD", domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
5743
+ if (!name || !returnUrl || !terms || !(Number(cappedAmount) > 0)) {
5744
+ throw new Error("createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount");
5745
+ }
5746
+ ;
5747
+ const data = await adminFetch({
5748
+ fetcher,
5749
+ adminAccessToken,
5750
+ domain,
5751
+ query: `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
5752
+ appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
5753
+ appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
5754
+ confirmationUrl
5755
+ userErrors { field message }
5756
+ }
5757
+ }`,
5758
+ variables: {
5759
+ lineItems: [
5760
+ {
5761
+ plan: {
5762
+ appRecurringPricingDetails: {
5763
+ interval: "EVERY_30_DAYS",
5764
+ price: { amount: Number(recurringPrice), currencyCode }
5765
+ }
5766
+ }
5767
+ },
5768
+ {
5769
+ plan: {
5770
+ appUsagePricingDetails: {
5771
+ cappedAmount: { amount: Number(cappedAmount), currencyCode },
5772
+ terms
5773
+ }
5774
+ }
5775
+ }
5776
+ ],
5777
+ name,
5778
+ returnUrl,
5779
+ test
5780
+ }
5781
+ });
5782
+ const payload = data == null ? void 0 : data.appSubscriptionCreate;
5783
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5784
+ if (userErrors.length) {
5785
+ throw new Error("Shopify subscription create failed: " + userErrors.map((entry) => entry.message).join("; "));
5786
+ }
5787
+ ;
5788
+ if (!(payload == null ? void 0 : payload.confirmationUrl)) {
5789
+ throw new Error("Shopify subscription create failed: no confirmation url returned");
5790
+ }
5791
+ ;
5792
+ return {
5793
+ confirmationUrl: payload.confirmationUrl,
5794
+ subscription: payload.appSubscription
5795
+ };
5796
+ };
5797
+ var createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = "USD", description, domain, idempotencyKey, subscriptionLineItemId }) => {
5798
+ var _a;
5799
+ if (!subscriptionLineItemId || !description || !(Number(amount) > 0)) {
5800
+ throw new Error("createUsageRecord requires subscriptionLineItemId, description, and a positive amount");
5801
+ }
5802
+ ;
5803
+ const data = await adminFetch({
5804
+ fetcher,
5805
+ adminAccessToken,
5806
+ domain,
5807
+ query: `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
5808
+ appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
5809
+ appUsageRecord { id }
5810
+ userErrors { field message }
5811
+ }
5812
+ }`,
5813
+ variables: {
5814
+ description,
5815
+ idempotencyKey: idempotencyKey ? String(idempotencyKey) : null,
5816
+ price: { amount: Number(amount), currencyCode },
5817
+ subscriptionLineItemId
5818
+ }
5819
+ });
5820
+ const payload = data == null ? void 0 : data.appUsageRecordCreate;
5821
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5822
+ if (userErrors.length || !((_a = payload == null ? void 0 : payload.appUsageRecord) == null ? void 0 : _a.id)) {
5823
+ throw new Error("Shopify usage record failed: " + (userErrors.map((entry) => entry.message).join("; ") || "no record returned"));
5824
+ }
5825
+ ;
5826
+ return { id: payload.appUsageRecord.id };
5827
+ };
5828
+ var getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
5829
+ var _a, _b;
5830
+ const data = await adminFetch({
5831
+ fetcher,
5832
+ adminAccessToken,
5833
+ domain,
5834
+ query: `{
5835
+ product( id : "${productId}" ) {
5836
+ variants( first : 100 ) {
5837
+ edges {
5838
+ node {
5839
+ id
5840
+ inventoryQuantity
5841
+ }
5842
+ }
5843
+ }
5844
+ }
5845
+ }`
5846
+ });
5847
+ const edges = ((_b = (_a = data == null ? void 0 : data.product) == null ? void 0 : _a.variants) == null ? void 0 : _b.edges) || [];
5848
+ return edges.reduce((accumulator, edge) => {
5849
+ var _a2, _b2;
5850
+ const id = (_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id;
5851
+ const quantity = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.inventoryQuantity;
5852
+ if (id) {
5853
+ accumulator[id] = Number.isFinite(quantity) ? quantity : 0;
5854
+ }
5855
+ ;
5856
+ return accumulator;
5857
+ }, {});
5858
+ };
5859
+ var getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
5860
+ var _a;
5861
+ const data = await adminFetch({
5862
+ fetcher,
5863
+ adminAccessToken,
5864
+ domain,
5865
+ query: `
5866
+ query ProductUpdatedAt($id: ID!) {
5867
+ product(id: $id) {
5868
+ updatedAt
5869
+ }
5870
+ }
5871
+ `,
5872
+ variables: { id: productId }
5873
+ });
5874
+ return ((_a = data == null ? void 0 : data.product) == null ? void 0 : _a.updatedAt) || null;
5875
+ };
5876
+ var NOT_ON_CHANNEL = "not available to the channel";
5877
+ var OUTDATED_FEEDBACK = "later version";
5878
+ var sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
5879
+ var _a, _b, _c;
5880
+ const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
5881
+ if (!productUpdatedAt) return null;
5882
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5883
+ const submit = async (version) => {
5884
+ const data = await adminFetch({
5885
+ fetcher,
5886
+ adminAccessToken,
5887
+ domain,
5888
+ query: `
5889
+ mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
5890
+ bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
5891
+ feedback {
5892
+ productId
5893
+ state
5894
+ }
5895
+ userErrors {
5896
+ field
5897
+ message
5898
+ }
5899
+ }
5900
+ }
5901
+ `,
5902
+ variables: {
5903
+ feedbackInput: [
5904
+ {
5905
+ productId,
5906
+ state,
5907
+ feedbackGeneratedAt: now,
5908
+ productUpdatedAt: version,
5909
+ messages
5910
+ }
5911
+ ]
5912
+ }
5913
+ });
5914
+ return data == null ? void 0 : data.bulkProductResourceFeedbackCreate;
5915
+ };
5916
+ let result = await submit(productUpdatedAt);
5917
+ if ((((_b = (_a = result == null ? void 0 : result.userErrors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) || "").toLowerCase().includes(OUTDATED_FEEDBACK)) {
5918
+ result = await submit(now);
5919
+ }
5920
+ ;
5921
+ const feedbackErrors = result == null ? void 0 : result.userErrors;
5922
+ if (feedbackErrors == null ? void 0 : feedbackErrors.length) {
5923
+ const message = feedbackErrors[0].message || "";
5924
+ if (message.toLowerCase().includes(NOT_ON_CHANNEL)) return null;
5925
+ throw new Error("Shopify product feedback failed: " + message);
5926
+ }
5927
+ ;
5928
+ return ((_c = result == null ? void 0 : result.feedback) == null ? void 0 : _c[0]) || null;
5929
+ };
5930
+
5931
+ // lib/shopify/billing.js
5932
+ var billing_exports = {};
5933
+ __export(billing_exports, {
5934
+ sendAppEvent: () => sendAppEvent
5935
+ });
5936
+ var APP_API = "https://api.shopify.com";
5937
+ var APP_API_VERSION = process.env.SHOPIFY_APP_API_VERSION || SHOPIFY_APP_API_VERSION;
5938
+ var cachedTokens = {};
5939
+ var fetchAppToken = async ({ clientId, clientSecret, fetcher }) => {
5940
+ const data = await request({
5941
+ fetcher,
5942
+ method: "POST",
5943
+ url: APP_API + "/auth/access_token",
5944
+ body: {
5945
+ client_id: clientId,
5946
+ client_secret: clientSecret,
5947
+ grant_type: "client_credentials"
5948
+ }
5949
+ });
5950
+ if (!(data == null ? void 0 : data.access_token)) {
5951
+ throw new Error("Shopify app token request failed: no access token returned");
5952
+ }
5953
+ ;
5954
+ const ttl = data.expires_in ? data.expires_in * 1e3 : 60 * 60 * 1e3;
5955
+ cachedTokens[clientId] = {
5956
+ expiresAt: Date.now() + ttl - 60 * 1e3,
5957
+ token: data.access_token
5958
+ };
5959
+ return data.access_token;
5960
+ };
5961
+ var getAppToken = async ({ clientId, clientSecret, fetcher }) => {
5962
+ const cached = cachedTokens[clientId];
5963
+ if (cached && cached.expiresAt > Date.now()) {
5964
+ return cached.token;
5965
+ }
5966
+ ;
5967
+ return fetchAppToken({ clientId, clientSecret, fetcher });
5968
+ };
5969
+ var toShopGid = (shopId) => String(shopId).startsWith("gid://") ? String(shopId) : "gid://shopify/Shop/" + shopId;
5970
+ var sendAppEvent = async ({ fetcher, clientId, clientSecret, eventHandle, idempotencyKey, reference: reference2, revision, shopId, timestamp, value }) => {
5971
+ if (!shopId || !eventHandle || !(Number(value) > 0)) {
5972
+ throw new Error("sendAppEvent requires shopId, eventHandle, and a positive value");
5973
+ }
5974
+ ;
5975
+ if (!clientId || !clientSecret) {
5976
+ throw new Error("sendAppEvent requires clientId and clientSecret");
5977
+ }
5978
+ ;
5979
+ const suffix = revision ? ".r" + revision : "";
5980
+ const key = idempotencyKey && String(idempotencyKey).slice(0, Math.max(0, 64 - suffix.length)) + suffix;
5981
+ const body = {
5982
+ attributes: {
5983
+ value: Number(value),
5984
+ ...reference2 && {
5985
+ reference: String(reference2).slice(0, 128)
5986
+ }
5987
+ },
5988
+ event_handle: eventHandle,
5989
+ shop_id: toShopGid(shopId),
5990
+ timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString(),
5991
+ ...key && {
5992
+ idempotency_key: key
5993
+ }
5994
+ };
5995
+ const send2 = async (token) => request({
5996
+ method: "POST",
5997
+ url: APP_API + "/app/" + APP_API_VERSION + "/events",
5998
+ headers: {
5999
+ "Authorization": "Bearer " + token
6000
+ },
6001
+ body
6002
+ });
6003
+ try {
6004
+ return await send2(await getAppToken({ clientId, clientSecret, fetcher }));
6005
+ } catch (error) {
6006
+ if ((error == null ? void 0 : error.status) === 401) {
6007
+ delete cachedTokens[clientId];
6008
+ return send2(await fetchAppToken({ clientId, clientSecret, fetcher }));
6009
+ }
6010
+ ;
6011
+ throw error;
6012
+ }
6013
+ };
6014
+
6015
+ // lib/shopify/oauth.js
6016
+ var oauth_exports = {};
6017
+ __export(oauth_exports, {
6018
+ SHOPIFY_REQUIRED_SCOPES: () => SHOPIFY_REQUIRED_SCOPES,
6019
+ createStorefrontToken: () => createStorefrontToken,
6020
+ getAdminToken: () => getAdminToken,
6021
+ getShop: () => getShop,
6022
+ missingScopes: () => missingScopes2,
6023
+ ping: () => ping,
6024
+ refreshAdminToken: () => refreshAdminToken,
6025
+ resolveConnectionSettings: () => resolveConnectionSettings
6026
+ });
6027
+ var required = (name, value) => {
6028
+ if (!value) {
6029
+ throw new Error("Shopify " + name + " is required");
6030
+ }
6031
+ ;
6032
+ return value;
6033
+ };
6034
+ var missingScopes2 = (granted) => {
6035
+ const grantedSet = new Set(
6036
+ (Array.isArray(granted) ? granted : String(granted || "").split(",")).map((scope) => scope.trim()).filter(Boolean)
6037
+ );
6038
+ const satisfied = (scope) => grantedSet.has(scope) || grantedSet.has(scope.replace(/(^|_)read_/, "$1write_"));
6039
+ return SHOPIFY_REQUIRED_SCOPES.filter((scope) => !satisfied(scope));
6040
+ };
6041
+ var ping = async ({ fetcher, adminAccessToken, domain }) => {
6042
+ var _a;
6043
+ const response = await fetch(
6044
+ `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`,
6045
+ {
6046
+ body: JSON.stringify({ query: "{ shop { name } }" }),
6047
+ headers: {
6048
+ "Content-Type": "application/json",
6049
+ "X-Shopify-Access-Token": adminAccessToken
6050
+ },
6051
+ method: "POST"
6052
+ }
6053
+ );
6054
+ if (!response.ok) {
6055
+ const text = await response.text().catch(() => "");
6056
+ const error = new Error(response.status + ": " + text);
6057
+ error.status = response.status;
6058
+ throw error;
6059
+ }
6060
+ ;
6061
+ const { errors } = await response.json().catch(() => ({}));
6062
+ if (errors == null ? void 0 : errors.length) {
6063
+ throw new Error("GraphQL: " + (((_a = errors[0]) == null ? void 0 : _a.message) || "unknown error"));
6064
+ }
6065
+ ;
6066
+ };
6067
+ var OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
6068
+ var parseOAuthErrorCode = (text) => {
6069
+ var _a;
6070
+ try {
6071
+ const parsed = JSON.parse(text);
6072
+ if (parsed == null ? void 0 : parsed.error) return parsed.error;
6073
+ } catch {
6074
+ }
6075
+ ;
6076
+ return ((_a = text.match(OAUTH_ERROR_TITLE_PATTERN)) == null ? void 0 : _a[1]) || null;
6077
+ };
6078
+ var shopifyOAuthFetch = async (url, body) => {
6079
+ const response = await fetch(url, {
6080
+ method: "POST",
6081
+ headers: {
6082
+ "Content-Type": "application/json"
6083
+ },
6084
+ body: JSON.stringify(body)
6085
+ });
6086
+ if (!response.ok) {
6087
+ const text = await response.text().catch(() => "");
6088
+ const error = new Error(response.status + ": " + text);
6089
+ error.status = response.status;
6090
+ error.code = parseOAuthErrorCode(text);
6091
+ throw error;
6092
+ }
6093
+ ;
6094
+ return response.json();
6095
+ };
6096
+ var getShop = async ({ fetcher, adminAccessToken, shop }) => {
6097
+ const data = await adminFetch({
6098
+ fetcher,
6099
+ adminAccessToken,
6100
+ domain: shop,
6101
+ query: `{
6102
+ shop {
6103
+ currencyCode
6104
+ id
6105
+ myshopifyDomain
6106
+ name
6107
+ }
6108
+ }`
6109
+ });
6110
+ const shopData = data == null ? void 0 : data.shop;
6111
+ if (!(shopData == null ? void 0 : shopData.id)) {
6112
+ throw new Error("Shopify shop lookup failed: no shop returned");
6113
+ }
6114
+ ;
6115
+ return {
6116
+ currency: shopData.currencyCode || null,
6117
+ id: shopData.id.split("/").pop(),
6118
+ myshopifyDomain: shopData.myshopifyDomain,
6119
+ name: shopData.name
6120
+ };
6121
+ };
6122
+ var createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
6123
+ var _a, _b, _c, _d, _e;
6124
+ try {
6125
+ const listed = await adminFetch({
6126
+ fetcher,
6127
+ adminAccessToken,
6128
+ domain: shop,
6129
+ query: `{
6130
+ shop {
6131
+ storefrontAccessTokens(first: 100) {
6132
+ edges {
6133
+ node {
6134
+ id
6135
+ title
6136
+ }
6137
+ }
6138
+ }
6139
+ }
6140
+ }`
6141
+ });
6142
+ const existing = (((_b = (_a = listed == null ? void 0 : listed.shop) == null ? void 0 : _a.storefrontAccessTokens) == null ? void 0 : _b.edges) || []).map((edge) => edge == null ? void 0 : edge.node);
6143
+ const orphaned = existing.filter((token) => (token == null ? void 0 : token.title) === "Drawbridge");
6144
+ await Promise.allSettled(
6145
+ orphaned.map((token) => adminFetch({
6146
+ adminAccessToken,
6147
+ domain: shop,
6148
+ query: `
6149
+ mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
6150
+ storefrontAccessTokenDelete(input: $input) {
6151
+ deletedStorefrontAccessTokenId
6152
+ userErrors {
6153
+ field
6154
+ message
6155
+ }
6156
+ }
6157
+ }
6158
+ `,
6159
+ variables: {
6160
+ input: {
6161
+ id: token.id
6162
+ }
6163
+ }
6164
+ }))
6165
+ );
6166
+ } catch {
6167
+ }
6168
+ const created = await adminFetch({
6169
+ fetcher,
6170
+ adminAccessToken,
6171
+ domain: shop,
6172
+ query: `
6173
+ mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
6174
+ storefrontAccessTokenCreate(input: $input) {
6175
+ storefrontAccessToken {
6176
+ accessToken
6177
+ }
6178
+ userErrors {
6179
+ field
6180
+ message
6181
+ }
6182
+ }
6183
+ }
6184
+ `,
6185
+ variables: {
6186
+ input: {
6187
+ title: "Drawbridge"
6188
+ }
6189
+ }
6190
+ });
6191
+ const createErrors = (_c = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _c.userErrors;
6192
+ if (createErrors == null ? void 0 : createErrors.length) {
6193
+ throw new Error("Shopify storefront token create failed: " + createErrors[0].message);
6194
+ }
6195
+ ;
6196
+ return ((_e = (_d = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _d.storefrontAccessToken) == null ? void 0 : _e.accessToken) || null;
6197
+ };
6198
+ var resolveTokenContext = async ({ connection, controller }) => {
6199
+ const decrypted = (connection == null ? void 0 : connection.settings) ? decrypt(connection.settings) : {};
6200
+ if ((decrypted == null ? void 0 : decrypted.ref) !== "shop") {
6201
+ return {
6202
+ raw: (connection == null ? void 0 : connection.settings) || null,
6203
+ session: null,
6204
+ settings: decrypted,
6205
+ shop: null,
6206
+ target: "connection"
6207
+ };
6208
+ }
6209
+ ;
6210
+ const shop = decrypted.shop;
6211
+ if (!controller || !shop) {
6212
+ return { raw: null, session: {}, settings: {}, shop, target: "shop" };
6213
+ }
6214
+ ;
6215
+ const record = await controller.get({ collection: "shop", query: { shop } });
6216
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : {};
6217
+ const storefront = (record == null ? void 0 : record.storefront) ? decrypt(record.storefront) : null;
6218
+ return {
6219
+ raw: (record == null ? void 0 : record.settings) || null,
6220
+ session,
6221
+ settings: {
6222
+ adminAccessToken: session.accessToken || null,
6223
+ refreshToken: session.refreshToken || null,
6224
+ refreshTokenExpiresAt: session.refreshTokenExpires || null,
6225
+ storefrontAccessToken: (storefront == null ? void 0 : storefront.token) || null,
6226
+ tokenExpiresAt: session.expires || null
6227
+ },
6228
+ shop,
6229
+ target: "shop"
6230
+ };
6231
+ };
6232
+ var resolveConnectionSettings = async ({ fetcher, connection, controller }) => {
6233
+ const { settings } = await resolveTokenContext({ connection, controller });
6234
+ return settings;
6235
+ };
6236
+ var refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
6237
+ var _a;
6238
+ const { raw, session, settings, shop, target } = await resolveTokenContext({ connection, controller });
6239
+ const domain = (connection == null ? void 0 : connection.shop) || ((_a = settings.source) == null ? void 0 : _a.domain) || settings.domain || shop;
6240
+ const { refreshToken } = settings;
6241
+ const data = await shopifyOAuthFetch(
6242
+ `https://${domain}/admin/oauth/access_token`,
6243
+ {
6244
+ grant_type: "refresh_token",
6245
+ // Throw before the grant: rotation RETIRES the old pair the moment it
6246
+ // returns, so a credential-less call that Shopify rejects opaquely is
6247
+ // the one failure mode worth being loud about.
6248
+ client_id: required("clientId", clientId),
6249
+ client_secret: required("clientSecret", clientSecret),
6250
+ refresh_token: refreshToken
6251
+ }
6252
+ );
6253
+ const adminAccessToken = data.access_token;
6254
+ const newRefreshToken = data.refresh_token;
6255
+ const expiresIn = data.expires_in;
6256
+ const tokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : null;
6257
+ const refreshTokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS);
6258
+ let liveToken = adminAccessToken;
6259
+ if (target === "shop") {
6260
+ const written = await controller.update({
6261
+ collection: "shop",
6262
+ data: {
6263
+ $set: {
6264
+ settings: encrypt({
6265
+ ...session,
6266
+ accessToken: adminAccessToken,
6267
+ expires: tokenExpiresAt,
6268
+ refreshToken: newRefreshToken,
6269
+ refreshTokenExpires: refreshTokenExpiresAt
6270
+ })
6271
+ }
6272
+ },
6273
+ query: {
6274
+ shop,
6275
+ ...raw && { settings: raw }
6276
+ }
6277
+ });
6278
+ if (!written) {
6279
+ const current = await controller.get({ collection: "shop", query: { shop } });
6280
+ if (!(current == null ? void 0 : current.settings)) {
6281
+ throw new Error("Shopify token refresh write-back failed: shop record missing for " + shop);
6282
+ }
6283
+ ;
6284
+ const currentSession = decrypt(current.settings);
6285
+ if ((currentSession == null ? void 0 : currentSession.accessToken) && currentSession.accessToken !== settings.adminAccessToken) {
6286
+ liveToken = currentSession.accessToken;
6287
+ } else {
6288
+ throw new Error("Shopify token refresh write-back failed for " + shop);
6289
+ }
6290
+ ;
6291
+ }
6292
+ ;
6293
+ } else {
6294
+ const written = await controller.update({
6295
+ collection: "connection",
6296
+ data: {
6297
+ $set: {
6298
+ settings: encrypt({
6299
+ ...settings,
6300
+ adminAccessToken,
6301
+ refreshToken: newRefreshToken,
6302
+ refreshTokenExpiresAt,
6303
+ tokenExpiresAt
6304
+ })
6305
+ }
6306
+ },
6307
+ query: {
6308
+ id: connection.id,
6309
+ ...raw && { settings: raw }
6310
+ }
6311
+ });
6312
+ if (!written) {
6313
+ const current = await controller.get({ collection: "connection", query: { id: connection.id } });
6314
+ const currentSettings = (current == null ? void 0 : current.settings) ? decrypt(current.settings) : null;
6315
+ if ((currentSettings == null ? void 0 : currentSettings.adminAccessToken) && currentSettings.adminAccessToken !== settings.adminAccessToken) {
6316
+ liveToken = currentSettings.adminAccessToken;
6317
+ } else {
6318
+ throw new Error("Shopify token refresh write-back failed for connection " + (connection == null ? void 0 : connection.id));
6319
+ }
6320
+ ;
6321
+ }
6322
+ ;
6323
+ }
6324
+ ;
6325
+ await ping({ adminAccessToken: liveToken, domain });
6326
+ return liveToken;
6327
+ };
6328
+ var getAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
6329
+ required("clientId", clientId);
6330
+ required("clientSecret", clientSecret);
6331
+ const settings = await resolveConnectionSettings({ connection, controller });
6332
+ const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
6333
+ if (!adminAccessToken) {
6334
+ return null;
6335
+ }
6336
+ ;
6337
+ if (!refreshToken) {
6338
+ return adminAccessToken;
6339
+ }
6340
+ ;
6341
+ const needsRefresh = !tokenExpiresAt || new Date(tokenExpiresAt) < new Date(Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS);
6342
+ if (needsRefresh) {
6343
+ return refreshAdminToken({ clientId, clientSecret, connection, controller });
6344
+ }
6345
+ ;
6346
+ return adminAccessToken;
6347
+ };
6348
+
6349
+ // lib/shopify/partner.js
6350
+ var partner_exports = {};
6351
+ __export(partner_exports, {
6352
+ getUsageChargeEvents: () => getUsageChargeEvents
6353
+ });
6354
+ var PARTNER_API = "https://partners.shopify.com";
6355
+ var getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
6356
+ var _a, _b, _c;
6357
+ if (!partnerToken || !organizationId || !appId) {
6358
+ throw new Error("getUsageChargeEvents requires partnerToken, organizationId, and appId");
6359
+ }
6360
+ ;
6361
+ const data = await request({
6362
+ fetcher,
6363
+ method: "POST",
6364
+ url: PARTNER_API + "/" + organizationId + "/api/" + PARTNER_API_VERSION + "/graphql.json",
6365
+ headers: {
6366
+ "X-Shopify-Access-Token": partnerToken
6367
+ },
6368
+ body: {
6369
+ query: `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
6370
+ events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
6371
+ edges {
6372
+ node {
6373
+ id
6374
+ occurredAt
6375
+ eventType
6376
+ shop { id myshopifyDomain }
6377
+ ... on Charge {
6378
+ chargeId
6379
+ chargeType
6380
+ amount { amount currencyCode }
6381
+ usageQuantity
6382
+ planHandle
6383
+ description
6384
+ }
6385
+ }
6386
+ }
6387
+ pageInfo { hasNextPage endCursor }
6388
+ }
6389
+ }`,
6390
+ variables: {
6391
+ filter: {
6392
+ eventTypes: ["CHARGE_USAGE"],
6393
+ subjectId: String(appId),
6394
+ ...occurredAtMin && { occurredAtMin },
6395
+ ...shopId && { shopId: String(shopId) }
6396
+ },
6397
+ first
6398
+ }
6399
+ }
6400
+ });
6401
+ if ((_a = data == null ? void 0 : data.errors) == null ? void 0 : _a.length) {
6402
+ throw new Error("Shopify partner events query failed: " + data.errors.map((entry) => entry.message).join("; "));
6403
+ }
6404
+ ;
6405
+ return (((_c = (_b = data == null ? void 0 : data.data) == null ? void 0 : _b.events) == null ? void 0 : _c.edges) || []).map((edge) => {
6406
+ var _a2, _b2, _c2, _d, _e, _f, _g, _h, _i, _j;
6407
+ return {
6408
+ amount: ((_a2 = edge.node) == null ? void 0 : _a2.amount) || null,
6409
+ chargeId: ((_b2 = edge.node) == null ? void 0 : _b2.chargeId) || null,
6410
+ // USAGE vs the recurring plan charge an approval emits — the
6411
+ // disambiguator callers filter on.
6412
+ chargeType: ((_c2 = edge.node) == null ? void 0 : _c2.chargeType) || null,
6413
+ description: ((_d = edge.node) == null ? void 0 : _d.description) || null,
6414
+ eventType: ((_e = edge.node) == null ? void 0 : _e.eventType) || null,
6415
+ id: ((_f = edge.node) == null ? void 0 : _f.id) || null,
6416
+ occurredAt: ((_g = edge.node) == null ? void 0 : _g.occurredAt) || null,
6417
+ planHandle: ((_h = edge.node) == null ? void 0 : _h.planHandle) || null,
6418
+ shop: ((_i = edge.node) == null ? void 0 : _i.shop) || null,
6419
+ usageQuantity: ((_j = edge.node) == null ? void 0 : _j.usageQuantity) ?? null
6420
+ };
6421
+ });
6422
+ };
6423
+
6424
+ // lib/shopify/storefront.js
6425
+ var storefront_exports = {};
6426
+ __export(storefront_exports, {
6427
+ cartCreate: () => cartCreate,
6428
+ cartGet: () => cartGet,
6429
+ cartLinesAdd: () => cartLinesAdd,
6430
+ cartLinesRemove: () => cartLinesRemove,
6431
+ cartLinesUpdate: () => cartLinesUpdate,
6432
+ createCheckoutToken: () => createCheckoutToken,
6433
+ getProduct: () => getProduct,
6434
+ getProductVariantsPage: () => getProductVariantsPage,
6435
+ getProducts: () => getProducts,
6436
+ probeVariantsOutOfStock: () => probeVariantsOutOfStock
6437
+ });
6438
+ var SORT_KEY_MAP = {
6439
+ bestSelling: "BEST_SELLING",
6440
+ createdAt: "CREATED_AT",
6441
+ id: "ID",
6442
+ price: "PRICE",
6443
+ productType: "PRODUCT_TYPE",
6444
+ title: "TITLE",
6445
+ updatedAt: "UPDATED_AT",
6446
+ vendor: "VENDOR"
6447
+ };
6448
+ var resolveProductSort = (sort) => {
6449
+ const entry = sort && Object.entries(sort)[0];
6450
+ if (!entry) return null;
6451
+ const [field, direction] = entry;
6452
+ const sortKey = SORT_KEY_MAP[field];
6453
+ if (!sortKey) return null;
6454
+ return {
6455
+ sortKey,
6456
+ reverse: Number(direction) < 0
6457
+ };
6458
+ };
6459
+ var storefrontUrl = (domain) => `https://${domain}/api/${SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`;
6460
+ var storefrontFetch = async ({ fetcher, domain, storefrontAccessToken, query, variables }) => {
6461
+ return request({
6462
+ method: "POST",
6463
+ url: storefrontUrl(domain),
6464
+ headers: {
6465
+ "X-Shopify-Storefront-Access-Token": storefrontAccessToken
6466
+ },
6467
+ body: {
6468
+ query,
6469
+ ...variables && { variables }
6470
+ }
6471
+ });
6472
+ };
6473
+ var shopCountryCache = {};
6474
+ var getShopCountry = async ({ domain, fetcher, storefrontAccessToken }) => {
6475
+ var _a, _b;
6476
+ if (shopCountryCache[domain]) return shopCountryCache[domain];
6477
+ const { data } = await storefrontFetch({
6478
+ fetcher,
6479
+ domain,
6480
+ storefrontAccessToken,
6481
+ query: `{
6482
+ shop {
6483
+ paymentSettings {
6484
+ countryCode
6485
+ }
6486
+ }
6487
+ }`
6488
+ });
6489
+ const countryCode = ((_b = (_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.paymentSettings) == null ? void 0 : _b.countryCode) || null;
6490
+ if (countryCode) shopCountryCache[domain] = countryCode;
6491
+ return countryCode;
6492
+ };
6493
+ var OUT_OF_STOCK_WARNING_CODES = [
6494
+ "MERCHANDISE_OUT_OF_STOCK",
6495
+ "MERCHANDISE_NOT_ENOUGH_STOCK"
6496
+ ];
6497
+ var probeVariantsOutOfStock = async ({ fetcher, domain, storefrontAccessToken, variantIds, countryCode }) => {
6498
+ var _a, _b;
6499
+ if (!(variantIds == null ? void 0 : variantIds.length)) return [];
6500
+ const resolvedCountry = countryCode || await getShopCountry({ domain, fetcher, storefrontAccessToken });
6501
+ const { data, errors } = await storefrontFetch({
6502
+ fetcher,
6503
+ domain,
6504
+ storefrontAccessToken,
6505
+ query: `
6506
+ mutation probe( $input : CartInput! ){
6507
+ cartCreate( input : $input ){
6508
+ cart {
6509
+ lines( first : 250 ){
6510
+ edges {
6511
+ node {
6512
+ id
6513
+ merchandise {
6514
+ ... on ProductVariant {
6515
+ id
6516
+ }
6517
+ }
6518
+ }
6519
+ }
6520
+ }
6521
+ }
6522
+ warnings {
6523
+ code
6524
+ message
6525
+ target
6526
+ }
6527
+ }
6528
+ }
6529
+ `,
6530
+ variables: {
6531
+ input: {
6532
+ lines: variantIds.map((merchandiseId) => ({
6533
+ merchandiseId,
6534
+ quantity: 1
6535
+ })),
6536
+ ...resolvedCountry && {
6537
+ buyerIdentity: {
6538
+ countryCode: resolvedCountry
6539
+ }
6540
+ }
6541
+ }
6542
+ }
6543
+ });
6544
+ if (errors) return [];
6545
+ const payload = data == null ? void 0 : data.cartCreate;
6546
+ const soldOut = new Set(
6547
+ ((payload == null ? void 0 : payload.warnings) || []).filter((warning) => OUT_OF_STOCK_WARNING_CODES.includes(warning == null ? void 0 : warning.code) && (warning == null ? void 0 : warning.target)).map((warning) => warning.target)
6548
+ );
6549
+ return (((_b = (_a = payload == null ? void 0 : payload.cart) == null ? void 0 : _a.lines) == null ? void 0 : _b.edges) || []).filter(({ node }) => soldOut.has(node.id)).map(({ node }) => {
6550
+ var _a2;
6551
+ return (_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.id;
6552
+ }).filter(Boolean);
6553
+ };
6554
+ var productsQuery = (search, cursor, limit, sort) => {
6555
+ const resolved = resolveProductSort(sort);
6556
+ const sortArgs = resolved ? `, sortKey: ${resolved.sortKey}, reverse: ${resolved.reverse}` : "";
6557
+ return `{
6558
+ products(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}${sortArgs}) {
6559
+ edges {
6560
+ node {
6561
+ id
6562
+ title
6563
+ description
6564
+ handle
6565
+ productType
6566
+ featuredImage {
6567
+ url
6568
+ }
6569
+ variants(first: 10) {
6570
+ edges {
6571
+ node {
6572
+ id
6573
+ title
6574
+ image {
6575
+ url
6576
+ }
6577
+ price {
6578
+ amount
6579
+ currencyCode
6580
+ }
6581
+ compareAtPrice {
6582
+ amount
6583
+ currencyCode
6584
+ }
6585
+ availableForSale
6586
+ requiresShipping
6587
+ }
6588
+ }
6589
+ }
6590
+ }
6591
+ }
6592
+ pageInfo {
6593
+ endCursor
6594
+ hasNextPage
6595
+ hasPreviousPage
6596
+ startCursor
6597
+ }
6598
+ }
6599
+ }`;
6600
+ };
6601
+ var productQuery = (productId) => `{
6602
+ product(id: "${productId}") {
6603
+ id
6604
+ title
6605
+ description
6606
+ handle
6607
+ productType
6608
+ featuredImage {
6609
+ url
6610
+ }
6611
+ variants(first: 10) {
6612
+ edges {
6613
+ node {
6614
+ id
6615
+ title
6616
+ image {
6617
+ url
6618
+ }
6619
+ price {
6620
+ amount
6621
+ currencyCode
6622
+ }
6623
+ compareAtPrice {
6624
+ amount
6625
+ currencyCode
6626
+ }
6627
+ availableForSale
6628
+ requiresShipping
6629
+ }
6630
+ }
6631
+ }
6632
+ }
6633
+ }`;
6634
+ var variantsQuery = (productId, cursor) => `{
6635
+ product(id: "${productId}") {
6636
+ variants(first: 250${cursor ? `, after: "${cursor}"` : ""}) {
6637
+ edges {
6638
+ node {
6639
+ id
6640
+ title
6641
+ image {
6642
+ url
6643
+ }
6644
+ price {
6645
+ amount
6646
+ currencyCode
6647
+ }
6648
+ compareAtPrice {
6649
+ amount
6650
+ currencyCode
6651
+ }
6652
+ availableForSale
6653
+ requiresShipping
6654
+ }
6655
+ }
6656
+ pageInfo {
6657
+ hasNextPage
6658
+ endCursor
6659
+ }
6660
+ }
6661
+ }
6662
+ }`;
6663
+ var getProducts = async ({ fetcher, domain, storefrontAccessToken, search, cursor, limit, sort }) => {
6664
+ const { data, errors } = await storefrontFetch({
6665
+ fetcher,
6666
+ domain,
6667
+ storefrontAccessToken,
6668
+ query: productsQuery(search, cursor, limit, sort)
6669
+ });
6670
+ if (errors) {
6671
+ throw new Error("Failed to fetch Shopify products");
6672
+ }
6673
+ ;
6674
+ return data == null ? void 0 : data.products;
6675
+ };
6676
+ var getProduct = async ({ fetcher, domain, storefrontAccessToken, productId }) => {
6677
+ var _a;
6678
+ const { data, errors } = await storefrontFetch({
6679
+ fetcher,
6680
+ domain,
6681
+ storefrontAccessToken,
6682
+ query: productQuery(productId)
6683
+ });
6684
+ if (errors) {
6685
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6686
+ }
6687
+ ;
6688
+ if (!(data == null ? void 0 : data.product)) {
6689
+ throw new Error("Shopify product not found");
6690
+ }
6691
+ ;
6692
+ return data.product;
6693
+ };
6694
+ var getProductVariantsPage = async ({ fetcher, domain, storefrontAccessToken, productId, cursor }) => {
6695
+ var _a;
6696
+ const { data, errors } = await storefrontFetch({
6697
+ fetcher,
6698
+ domain,
6699
+ storefrontAccessToken,
6700
+ query: variantsQuery(productId, cursor)
6701
+ });
6702
+ if (errors) {
6703
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6704
+ }
6705
+ ;
6706
+ if (!(data == null ? void 0 : data.product)) {
6707
+ throw new Error("Shopify product not found");
6708
+ }
6709
+ ;
6710
+ return data.product.variants;
6711
+ };
6712
+ var CART_FIELDS = `
6713
+ id
6714
+ checkoutUrl
6715
+ lines(first: 100) {
6716
+ edges {
6717
+ node {
6718
+ id
6719
+ quantity
6720
+ merchandise {
6721
+ ... on ProductVariant {
6722
+ id
6723
+ title
6724
+ availableForSale
6725
+ image {
6726
+ url
6727
+ }
6728
+ price {
6729
+ amount
6730
+ currencyCode
6731
+ }
6732
+ product {
6733
+ title
6734
+ }
6735
+ }
6736
+ }
6737
+ }
6738
+ }
6739
+ }
6740
+ `;
6741
+ var mapCart = (cart) => {
6742
+ var _a;
6743
+ if (!cart) return null;
6744
+ return {
6745
+ checkoutUrl: cart.checkoutUrl,
6746
+ id: cart.id,
6747
+ lines: (((_a = cart.lines) == null ? void 0 : _a.edges) || []).map(({ node }) => {
6748
+ var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
6749
+ return {
6750
+ availableForSale: ((_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.availableForSale) ?? true,
6751
+ currency: ((_c = (_b = node == null ? void 0 : node.merchandise) == null ? void 0 : _b.price) == null ? void 0 : _c.currencyCode) || null,
6752
+ id: node.id,
6753
+ image: ((_e = (_d = node == null ? void 0 : node.merchandise) == null ? void 0 : _d.image) == null ? void 0 : _e.url) || null,
6754
+ price: ((_g = (_f = node == null ? void 0 : node.merchandise) == null ? void 0 : _f.price) == null ? void 0 : _g.amount) || null,
6755
+ productTitle: ((_i = (_h = node == null ? void 0 : node.merchandise) == null ? void 0 : _h.product) == null ? void 0 : _i.title) || null,
6756
+ quantity: node.quantity,
6757
+ variantId: (_j = node == null ? void 0 : node.merchandise) == null ? void 0 : _j.id,
6758
+ variantTitle: ((_k = node == null ? void 0 : node.merchandise) == null ? void 0 : _k.title) || null
6759
+ };
6760
+ })
6761
+ };
6762
+ };
6763
+ var firstUserError = (userErrors) => {
6764
+ const error = userErrors == null ? void 0 : userErrors[0];
6765
+ if (!error) return null;
6766
+ const message = new Error(error.message || "Shopify cart error");
6767
+ message.userError = true;
6768
+ return message;
6769
+ };
6770
+ var createCheckoutToken = async ({ fetcher, clientId, clientSecret } = {}) => {
6771
+ if (!clientId || !clientSecret) {
6772
+ throw new Error("createCheckoutToken requires clientId and clientSecret");
6773
+ }
6774
+ ;
6775
+ const response = await request({
6776
+ fetcher,
6777
+ method: "POST",
6778
+ url: "https://api.shopify.com/auth/access_token",
6779
+ body: {
6780
+ client_id: clientId,
6781
+ client_secret: clientSecret,
6782
+ grant_type: "client_credentials"
6783
+ }
6784
+ });
6785
+ const token = response == null ? void 0 : response.access_token;
6786
+ if (!token) {
6787
+ throw new Error("Failed to create Shopify checkout token");
6788
+ }
6789
+ ;
6790
+ return token;
6791
+ };
6792
+ var cartCreate = async ({ fetcher, domain, storefrontAccessToken, lines }) => {
6793
+ var _a, _b;
6794
+ const { data, errors } = await storefrontFetch({
6795
+ fetcher,
6796
+ domain,
6797
+ storefrontAccessToken,
6798
+ query: `
6799
+ mutation cartCreate($input: CartInput!) {
6800
+ cartCreate(input: $input) {
6801
+ cart {
6802
+ ${CART_FIELDS}
6803
+ }
6804
+ userErrors {
6805
+ field
6806
+ message
6807
+ }
6808
+ }
6809
+ }
6810
+ `,
6811
+ variables: {
6812
+ input: {
6813
+ lines
6814
+ }
6815
+ }
6816
+ });
6817
+ if (errors) {
6818
+ throw new Error("Failed to create Shopify cart");
6819
+ }
6820
+ ;
6821
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartCreate) == null ? void 0 : _a.userErrors);
6822
+ if (userError) throw userError;
6823
+ return mapCart((_b = data == null ? void 0 : data.cartCreate) == null ? void 0 : _b.cart);
6824
+ };
6825
+ var cartGet = async ({ fetcher, domain, storefrontAccessToken, cartId }) => {
6826
+ const { data, errors } = await storefrontFetch({
6827
+ fetcher,
6828
+ domain,
6829
+ storefrontAccessToken,
6830
+ query: `
6831
+ query cart($id: ID!) {
6832
+ cart(id: $id) {
6833
+ ${CART_FIELDS}
6834
+ }
6835
+ }
6836
+ `,
6837
+ variables: {
6838
+ id: cartId
6839
+ }
6840
+ });
6841
+ if (errors) {
6842
+ throw new Error("Failed to fetch Shopify cart");
6843
+ }
6844
+ ;
6845
+ return mapCart(data == null ? void 0 : data.cart);
6846
+ };
6847
+ var cartLinesAdd = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6848
+ var _a, _b;
6849
+ const { data, errors } = await storefrontFetch({
6850
+ fetcher,
6851
+ domain,
6852
+ storefrontAccessToken,
6853
+ query: `
6854
+ mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
6855
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
6856
+ cart {
6857
+ ${CART_FIELDS}
6858
+ }
6859
+ userErrors {
6860
+ field
6861
+ message
6862
+ }
6863
+ }
6864
+ }
6865
+ `,
6866
+ variables: {
6867
+ cartId,
6868
+ lines
6869
+ }
6870
+ });
6871
+ if (errors) {
6872
+ throw new Error("Failed to add Shopify cart lines");
6873
+ }
6874
+ ;
6875
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _a.userErrors);
6876
+ if (userError) throw userError;
6877
+ return mapCart((_b = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _b.cart);
6878
+ };
6879
+ var cartLinesRemove = async ({ fetcher, domain, storefrontAccessToken, cartId, lineIds }) => {
6880
+ var _a, _b;
6881
+ const { data, errors } = await storefrontFetch({
6882
+ fetcher,
6883
+ domain,
6884
+ storefrontAccessToken,
6885
+ query: `
6886
+ mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
6887
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
6888
+ cart {
6889
+ ${CART_FIELDS}
6890
+ }
6891
+ userErrors {
6892
+ field
6893
+ message
6894
+ }
6895
+ }
6896
+ }
6897
+ `,
6898
+ variables: {
6899
+ cartId,
6900
+ lineIds
6901
+ }
6902
+ });
6903
+ if (errors) {
6904
+ throw new Error("Failed to remove Shopify cart lines");
6905
+ }
6906
+ ;
6907
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _a.userErrors);
6908
+ if (userError) throw userError;
6909
+ return mapCart((_b = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _b.cart);
6910
+ };
6911
+ var cartLinesUpdate = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6912
+ var _a, _b;
6913
+ const { data, errors } = await storefrontFetch({
6914
+ fetcher,
6915
+ domain,
6916
+ storefrontAccessToken,
6917
+ query: `
6918
+ mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
6919
+ cartLinesUpdate(cartId: $cartId, lines: $lines) {
6920
+ cart {
6921
+ ${CART_FIELDS}
6922
+ }
6923
+ userErrors {
6924
+ field
6925
+ message
6926
+ }
6927
+ }
6928
+ }
6929
+ `,
6930
+ variables: {
6931
+ cartId,
6932
+ lines
6933
+ }
6934
+ });
6935
+ if (errors) {
6936
+ throw new Error("Failed to update Shopify cart lines");
6937
+ }
6938
+ ;
6939
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _a.userErrors);
6940
+ if (userError) throw userError;
6941
+ return mapCart((_b = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _b.cart);
6942
+ };
6943
+
6944
+ // lib/email.js
6945
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
6946
+ var toCanonicalEmail = (value) => {
6947
+ if (!value || typeof value !== "string") return null;
6948
+ const email = value.trim().toLowerCase();
6949
+ const at = email.lastIndexOf("@");
6950
+ if (at < 1 || at === email.length - 1) return null;
6951
+ let local = email.slice(0, at);
6952
+ const domain = email.slice(at + 1);
6953
+ const plus = local.indexOf("+");
6954
+ if (plus > 0) local = local.slice(0, plus);
6955
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
6956
+ if (!local) return null;
6957
+ return local + "@" + domain;
6958
+ };
6959
+
6960
+ // lib/oauth/index.js
6961
+ var lifetimes = {
6962
+ access: 60 * 60 * 8,
6963
+ // 8 hours
6964
+ authorizationCode: 10 * 60,
6965
+ // 10 minutes
6966
+ code: 15 * 60,
6967
+ // 15 minutes (email OTC)
6968
+ refresh: 30 * 24 * 60 * 60
6969
+ // 30 days
6970
+ };
6971
+ var tokenTypes = {
6972
+ access: "access",
6973
+ oauthState: "oauth.state",
6974
+ pat: "pat",
6975
+ refresh: "refresh"
6976
+ };
6977
+ var scopes = [
6978
+ "profile:read",
6979
+ "profile:write",
6980
+ "organization:read",
6981
+ "organization:write",
6982
+ "campaigns:read",
6983
+ "campaigns:write",
6984
+ "contacts:read",
6985
+ "contacts:write",
6986
+ "workflows:read",
6987
+ "workflows:write",
6988
+ "connections:read",
6989
+ "connections:write",
6990
+ "billing:read",
6991
+ "billing:write",
6992
+ "admin"
6993
+ ];
6994
+ var developer = [
6995
+ "profile:read",
6996
+ "organization:read",
6997
+ "campaigns:read",
6998
+ "campaigns:write",
6999
+ "contacts:read",
7000
+ "contacts:write",
7001
+ "workflows:read",
7002
+ "workflows:write",
7003
+ "connections:read",
7004
+ "connections:write"
7005
+ ];
7006
+ var scopeLabels = {
7007
+ "profile:read": "Read own profile",
7008
+ "profile:write": "Update own profile",
7009
+ "organization:read": "Read organization info",
7010
+ "organization:write": "Manage organization",
7011
+ "campaigns:read": "Read campaigns",
7012
+ "campaigns:write": "Manage campaigns",
7013
+ "contacts:read": "Read contacts",
7014
+ "contacts:write": "Manage contacts",
7015
+ "workflows:read": "Read workflows",
7016
+ "workflows:write": "Manage workflows",
7017
+ "connections:read": "Read integrations",
7018
+ "connections:write": "Manage integrations",
7019
+ "billing:read": "Read billing",
7020
+ "billing:write": "Manage billing",
7021
+ admin: "Admin access"
7022
+ };
7023
+ var toOption = (scope) => ({
7024
+ key: scopeLabels[scope] || scope,
7025
+ value: scope
7026
+ });
7027
+ var developerOptions = developer.map(toOption);
7028
+ var scopeOptions = scopes.map(toOption);
7029
+ var hashToken = (raw) => hash(raw, process.env.HMAC_OAUTH_TOKEN_KEY);
7030
+
7031
+ // lib/sanitize.js
7032
+ import disposableDomains from "disposable-email-domains/index.js";
7033
+ var disposableBlacklist = new Set(disposableDomains.map((d) => d.toLowerCase()));
7034
+ var sanitizeDomain = (value, pattern) => {
7035
+ if (!value) return value;
7036
+ const cleaned = value.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
7037
+ if (pattern) {
7038
+ const match = cleaned.match(pattern);
7039
+ return match ? match[0] : cleaned;
7040
+ }
7041
+ return cleaned;
7042
+ };
7043
+
7044
+ // lib/slugify.js
7045
+ import slug from "slugify";
7046
+ var slugify = (value, nochars = false) => {
7047
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
7048
+ const replacement = nochars ? "" : "-";
7049
+ return slug(
7050
+ value,
7051
+ {
7052
+ remove,
7053
+ replacement,
7054
+ lower: true,
7055
+ trim: true
7056
+ }
7057
+ );
7058
+ };
7059
+
7060
+ // lib/connections/manifests/shopify.js
7061
+ var client = Object.freeze({
7062
+ admin: admin_exports,
7063
+ billing: billing_exports,
7064
+ oauth: oauth_exports,
7065
+ partner: partner_exports,
7066
+ storefront: storefront_exports
7067
+ });
7068
+ var toLine = ({
7069
+ price,
7070
+ product_id: productId,
7071
+ quantity,
7072
+ title,
7073
+ variant_id: variantId,
7074
+ variant_title: variantTitle
7075
+ }) => ({
7076
+ price: parseFloat(price) || 0,
7077
+ productId: productId ? "gid://shopify/Product/" + productId : null,
7078
+ quantity: quantity || 1,
7079
+ title: title || null,
7080
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
7081
+ variantTitle: variantTitle || null
7082
+ });
7083
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
7084
+ (acc, item) => {
7085
+ const attrs = (item.properties || []).reduce(
7086
+ (map, { name, value }) => {
7087
+ map[name] = value;
7088
+ return map;
7089
+ },
7090
+ {}
7091
+ );
7092
+ if (!attrs["_drwbrdg_ca"]) return acc;
7093
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
7094
+ const line = toLine(item);
7095
+ acc.attributedGross += line.price * line.quantity;
7096
+ acc.attributedLines.push(line);
7097
+ return acc;
7098
+ },
7099
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
7100
+ );
7101
+ var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
7102
+ var blockedReason = (discount) => {
7103
+ var _a;
7104
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
7105
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
7106
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
7107
+ return "This discount is limited to specific buyers in Shopify, so a code issued to an entrant won't work. Set it to all customers to use it here.";
7108
+ }
7109
+ ;
7110
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
7111
+ return "This discount has reached its total usage limit.";
7112
+ }
7113
+ ;
7114
+ return null;
7115
+ };
7116
+ var discountWarning = (discount) => {
7117
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
7118
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
7119
+ }
7120
+ ;
7121
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
7122
+ return null;
7123
+ };
7124
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
7125
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
7126
+ var OAUTH_ERROR_SOURCE = "oauth";
7127
+ var BILLING_ERROR_SOURCE = "billing";
7128
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
7129
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
7130
+ var inbound = {
7131
+ headers: {
7132
+ event: "x-shopify-topic",
7133
+ id: "x-shopify-webhook-id",
7134
+ shop: "x-shopify-shop-domain",
7135
+ signature: "x-shopify-hmac-sha256"
7136
+ },
7137
+ signature: {
7138
+ algorithm: "sha256",
7139
+ encoding: "base64",
7140
+ secret: "SHOPIFY_API_SECRET"
7141
+ }
7142
+ };
5135
7143
  var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
5136
7144
  "customers/data_request",
5137
7145
  "customers/redact",
5138
7146
  "shop/redact"
5139
7147
  ]);
7148
+ var refusal = (message, status) => Object.assign(new Error(message), { status });
7149
+ var LINK_TOKEN_TTL_MS = 30 * 60 * 1e3;
7150
+ var CONNECTABLE_STATUSES = ["active", "unsubscribed"];
7151
+ var merchantOrganizations = ({ read, user }) => read.aggregate({
7152
+ collection: "organization",
7153
+ pipeline: [
7154
+ // A member qualifies only at `manage`; an owner always qualifies.
7155
+ {
7156
+ $lookup: {
7157
+ as: "membership",
7158
+ from: "member",
7159
+ let: { organization: "$id" },
7160
+ pipeline: [
7161
+ {
7162
+ $match: {
7163
+ $expr: { $eq: ["$organization", "$$organization"] },
7164
+ "capabilities.connections": "manage",
7165
+ status: "accepted",
7166
+ user
7167
+ }
7168
+ },
7169
+ { $limit: 1 },
7170
+ { $project: { _id: 0, organization: 1 } }
7171
+ ]
7172
+ }
7173
+ },
7174
+ {
7175
+ $match: {
7176
+ status: { $ne: "canceled" },
7177
+ $or: [{ owner: user }, { "membership.0": { $exists: true } }]
7178
+ }
7179
+ },
7180
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
7181
+ // the list can read the real plan and conversion rate. Without it every org
7182
+ // rendered as free tier.
7183
+ { $lookup: { as: "subscription", foreignField: "id", from: "subscription", localField: "subscription" } },
7184
+ { $unwind: { path: "$subscription", preserveNullAndEmptyArrays: true } },
7185
+ {
7186
+ $lookup: {
7187
+ as: "connection",
7188
+ foreignField: "organization",
7189
+ from: "connection",
7190
+ localField: "id",
7191
+ pipeline: [
7192
+ { $match: { slug: "shopify" } },
7193
+ { $limit: 1 },
7194
+ { $project: { _id: 0, id: 1, shop: 1, status: 1 } }
7195
+ ]
7196
+ }
7197
+ },
7198
+ { $unwind: { path: "$connection", preserveNullAndEmptyArrays: true } },
7199
+ { $project: { _id: 0, billingStatus: "$status", connection: 1, id: 1, name: "$title", subscription: 1 } },
7200
+ { $sort: { name: 1 } }
7201
+ ]
7202
+ });
7203
+ var storePlan = async ({ record, shopify }) => {
7204
+ var _a;
7205
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7206
+ if (!(settings == null ? void 0 : settings.accessToken)) return { active: false, name: null };
7207
+ try {
7208
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7209
+ adminAccessToken: settings.accessToken,
7210
+ domain: record.shop
7211
+ });
7212
+ const active = (subscriptions || []).length > 0;
7213
+ const metered = active && subscriptions.some((subscription) => subscription.metered === true) ? true : null;
7214
+ return { active, metered, name: active ? ((_a = subscriptions[0]) == null ? void 0 : _a.name) || null : null };
7215
+ } catch (_) {
7216
+ return { active: false, name: null, unavailable: true };
7217
+ }
7218
+ };
7219
+ var releaseApprovals = async ({ context, read, shopify }) => {
7220
+ var _a;
7221
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7222
+ if (!shop) throw refusal("shop is required", 400);
7223
+ const record = await read.get({ collection: "shop", query: { shop } });
7224
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7225
+ if (!(settings == null ? void 0 : settings.accessToken)) throw refusal("Store is not installed", 409);
7226
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7227
+ adminAccessToken: settings.accessToken,
7228
+ domain: shop
7229
+ });
7230
+ for (const subscription of subscriptions || []) {
7231
+ const data = await shopify.admin.adminFetch({
7232
+ adminAccessToken: settings.accessToken,
7233
+ domain: shop,
7234
+ query: "mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }",
7235
+ variables: { id: subscription.id }
7236
+ });
7237
+ const [userError] = ((_a = data == null ? void 0 : data.appSubscriptionCancel) == null ? void 0 : _a.userErrors) || [];
7238
+ if (userError) throw refusal(userError.message, 422);
7239
+ }
7240
+ return {
7241
+ message: "Released " + (subscriptions || []).length + " approval(s).",
7242
+ request: { shop },
7243
+ result: { ok: true }
7244
+ };
7245
+ };
7246
+ var LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
7247
+ var meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
7248
+ var _a;
7249
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain: shop });
7250
+ let metered = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
7251
+ if (!((partner == null ? void 0 : partner.partnerToken) && (partner == null ? void 0 : partner.partnerOrgId) && (partner == null ? void 0 : partner.partnerAppId) && shopId)) {
7252
+ return { boundaries: [], ledger: false, metered };
7253
+ }
7254
+ const accruals = await shopify.partner.getUsageChargeEvents({
7255
+ appId: "gid://shopify/App/" + partner.partnerAppId,
7256
+ first: 10,
7257
+ occurredAtMin: ledgerFloor.toISOString(),
7258
+ organizationId: partner.partnerOrgId,
7259
+ partnerToken: partner.partnerToken,
7260
+ shopId: "gid://shopify/Shop/" + shopId
7261
+ });
7262
+ const boundaries = (accruals || []).filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
7263
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
7264
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
7265
+ return { boundaries, ledger: true, metered };
7266
+ };
7267
+ var toVariant = (edge) => {
7268
+ var _a, _b, _c, _d;
7269
+ return {
7270
+ availableForSale: edge.node.availableForSale || false,
7271
+ currency: ((_a = edge.node.price) == null ? void 0 : _a.currencyCode) || null,
7272
+ id: edge.node.id,
7273
+ image: ((_b = edge.node.image) == null ? void 0 : _b.url) || null,
7274
+ price: parseFloat((_c = edge.node.price) == null ? void 0 : _c.amount) || null,
7275
+ requiresShipping: edge.node.requiresShipping || false,
7276
+ sale: parseFloat((_d = edge.node.compareAtPrice) == null ? void 0 : _d.amount) || null,
7277
+ title: edge.node.title !== "Default Title" ? edge.node.title : null
7278
+ };
7279
+ };
7280
+ var applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
7281
+ try {
7282
+ const outOfStock = new Set(await shopify.storefront.probeVariantsOutOfStock({
7283
+ domain,
7284
+ storefrontAccessToken,
7285
+ variantIds: variants.map((variant) => variant.id)
7286
+ }));
7287
+ return variants.map((variant) => ({
7288
+ ...variant,
7289
+ availableForSale: variant.availableForSale && !outOfStock.has(variant.id)
7290
+ }));
7291
+ } catch (_) {
7292
+ return variants;
7293
+ }
7294
+ };
7295
+ var sendFeedback = async ({ adminToken, connection, messages, productId, shopify, state }) => {
7296
+ try {
7297
+ await shopify.admin.sendProductResourceFeedback({
7298
+ adminAccessToken: await adminToken(),
7299
+ domain: connection.shop,
7300
+ messages,
7301
+ productId,
7302
+ state
7303
+ });
7304
+ return null;
7305
+ } catch (error) {
7306
+ return error;
7307
+ }
7308
+ };
7309
+ var resolveProduct = async ({ connection, context, read }) => {
7310
+ const byId = (context == null ? void 0 : context.product) ? await read.get({ collection: "product", query: { id: context.product } }) : null;
7311
+ const row2 = byId || ((context == null ? void 0 : context.providerId) && ((context == null ? void 0 : context.shop) || (connection == null ? void 0 : connection.shop)) ? await read.get({
7312
+ collection: "product",
7313
+ query: {
7314
+ "provider.id": context.providerId,
7315
+ "provider.slug": "shopify",
7316
+ "source.domain": context.shop || connection.shop
7317
+ }
7318
+ }) : null);
7319
+ return row2;
7320
+ };
7321
+ var UNAVAILABLE_FEEDBACK = "Drawbridge can't display this product. Check it is active and available in the United States.";
7322
+ var NOT_FOUND = "Shopify product not found";
7323
+ var syncProduct = async ({ adminToken, connection, context, read, settings, shopify }) => {
7324
+ var _a, _b, _c;
7325
+ const row2 = await resolveProduct({ connection, context, read });
7326
+ if (!row2) return { message: "No product row to sync.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7327
+ const domain = connection.shop;
7328
+ const providerId = (_a = row2.provider) == null ? void 0 : _a.id;
7329
+ const request2 = { product: row2.id, providerId: providerId || null, shop: domain };
7330
+ let product;
7331
+ try {
7332
+ product = await shopify.storefront.getProduct({
7333
+ domain,
7334
+ productId: providerId,
7335
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7336
+ });
7337
+ } catch (error) {
7338
+ if (error.message !== NOT_FOUND) throw error;
7339
+ const failed2 = await sendFeedback({
7340
+ adminToken,
7341
+ connection,
7342
+ messages: [UNAVAILABLE_FEEDBACK],
7343
+ productId: providerId,
7344
+ shopify,
7345
+ state: "REQUIRES_ACTION"
7346
+ });
7347
+ return {
7348
+ failed: failed2 ? [failed2.message] : void 0,
7349
+ message: "Storefront cannot see this product \u2014 deactivated.",
7350
+ request: request2,
7351
+ result: { status: "inactive" },
7352
+ writes: [
7353
+ {
7354
+ collection: "product",
7355
+ data: { $set: { status: "inactive" } },
7356
+ operation: "update",
7357
+ query: { id: row2.id }
7358
+ },
7359
+ {
7360
+ collection: "advertisement",
7361
+ data: { $set: { status: "drafted" } },
7362
+ multiple: true,
7363
+ operation: "update",
7364
+ query: { product: row2.id }
7365
+ }
7366
+ ]
7367
+ };
7368
+ }
7369
+ const variants = await applyOutOfStock({
7370
+ domain,
7371
+ shopify,
7372
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7373
+ variants: (((_b = product == null ? void 0 : product.variants) == null ? void 0 : _b.edges) || []).map(toVariant)
7374
+ });
7375
+ const failed = await sendFeedback({
7376
+ adminToken,
7377
+ connection,
7378
+ messages: [],
7379
+ productId: providerId,
7380
+ shopify,
7381
+ state: "ACCEPTED"
7382
+ });
7383
+ return {
7384
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
7385
+ // written above; a product with thousands of variants becomes N short
7386
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
7387
+ enqueues: [{
7388
+ data: { operation: "variants", product: row2.id },
7389
+ name: "sync",
7390
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7391
+ queue: "product.shopify.variants"
7392
+ }],
7393
+ failed: failed ? [failed.message] : void 0,
7394
+ message: "Synced " + (product.title || "product") + ".",
7395
+ request: request2,
7396
+ result: { status: "active", variants: variants.length },
7397
+ writes: [{
7398
+ collection: "product",
7399
+ data: {
7400
+ $set: {
7401
+ description: product.description || null,
7402
+ image: ((_c = product.featuredImage) == null ? void 0 : _c.url) || null,
7403
+ // A successful sync PROVES channel visibility, which reverses the
7404
+ // deactivation above. Ads stay drafted.
7405
+ status: "active",
7406
+ title: product.title || null,
7407
+ type: product.productType || null,
7408
+ url: domain && product.handle ? "https://" + domain + "/products/" + product.handle : null,
7409
+ variants
7410
+ }
7411
+ },
7412
+ operation: "update",
7413
+ query: { id: row2.id }
7414
+ }]
7415
+ };
7416
+ };
7417
+ var syncVariants = async ({ connection, context, read, settings, shopify }) => {
7418
+ var _a, _b, _c;
7419
+ const row2 = await resolveProduct({ connection, context, read });
7420
+ if (!row2) return { message: "No product row to paginate.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7421
+ const cursor = (context == null ? void 0 : context.cursor) || null;
7422
+ const request2 = { cursor, product: row2.id };
7423
+ let page;
7424
+ try {
7425
+ page = await shopify.storefront.getProductVariantsPage({
7426
+ cursor,
7427
+ domain: connection.shop,
7428
+ productId: (_a = row2.provider) == null ? void 0 : _a.id,
7429
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7430
+ });
7431
+ } catch (error) {
7432
+ if (error.message !== NOT_FOUND) throw error;
7433
+ return { message: "Storefront lost this product mid-pagination \u2014 stopping.", request: request2, skipped: true };
7434
+ }
7435
+ const variants = await applyOutOfStock({
7436
+ domain: connection.shop,
7437
+ shopify,
7438
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7439
+ variants: (page.edges || []).map(toVariant)
7440
+ });
7441
+ return {
7442
+ ...((_b = page.pageInfo) == null ? void 0 : _b.hasNextPage) && { enqueues: [{
7443
+ data: { cursor: page.pageInfo.endCursor, operation: "variants", product: row2.id },
7444
+ name: "page",
7445
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7446
+ queue: "product.shopify.variants"
7447
+ }] },
7448
+ message: variants.length + " variant(s) on this page.",
7449
+ request: request2,
7450
+ result: { hasNextPage: Boolean((_c = page.pageInfo) == null ? void 0 : _c.hasNextPage), variants: variants.length },
7451
+ writes: [{
7452
+ collection: "product",
7453
+ data: cursor ? { $push: { variants: { $each: variants } } } : { $set: { variants } },
7454
+ operation: "update",
7455
+ query: { id: row2.id }
7456
+ }]
7457
+ };
7458
+ };
5140
7459
  var shopify_default2 = {
5141
7460
  // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
5142
7461
  // a BUTTON LABEL inside the copy object next to a url — the title and the
@@ -5323,8 +7642,8 @@ var shopify_default2 = {
5323
7642
  // string; this answers what is missing from it.
5324
7643
  //
5325
7644
  // `shopify` is injected for the same reason it is everywhere else — this
5326
- // package cannot import @drawbridge/shopify, which depends on it.
5327
- scopes: ({ scope }, { shopify } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
7645
+ // client is imported here and defaulted, never wired in by a caller.
7646
+ scopes: ({ scope }, { shopify = client } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
5328
7647
  // Shopify's install grant is exchanged inside its own app flow, not
5329
7648
  // through the shared OAuth runner.
5330
7649
  token: false
@@ -5338,17 +7657,53 @@ var shopify_default2 = {
5338
7657
  // can reference another), `dispatch` (the caller's own coordinator table,
5339
7658
  // for the hooks that are dispatches).
5340
7659
  commerce: {
5341
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
5342
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
5343
- // exactly why it never became a hook: a hook is reached to have its effects
5344
- // performed, and this one has none to perform. It moves here when the vendor
5345
- // client moves into the manifest and `subdomain.js` stops importing the
5346
- // package directly.
5347
- cart: false,
7660
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
7661
+ // the storefront client from api/route/subdomain.js.
7662
+ //
7663
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
7664
+ // become a hook a hook is usually reached to have its effects performed
7665
+ // and this one has none. That reasoning was wrong: a hook is also the one
7666
+ // place a vendor's client is allowed to be called from, and leaving the
7667
+ // cart out meant a public route importing a vendor SDK directly.
7668
+ //
7669
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
7670
+ // uses for its topic table. Six slots would be six names in the closed
7671
+ // vocabulary for one question — what does this shopper's cart look like
7672
+ // now — asked six ways.
7673
+ //
7674
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
7675
+ // mutations answer "that variant is sold out" as a userError rather than a
7676
+ // transport failure, and the route it replaced already told the two apart.
7677
+ // runHook carries `status` off a thrown error, so it survives to the
7678
+ // caller; the rest of a thrown error's shape does not.
7679
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {}) => {
7680
+ const { cartId, lineIds, lines, operation } = context || {};
7681
+ const { domain, storefrontAccessToken } = settings || {};
7682
+ if (operation === "checkout") {
7683
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
7684
+ return { message: "Checkout token minted.", result: { token } };
7685
+ }
7686
+ const storefront = { domain, fetcher, storefrontAccessToken };
7687
+ try {
7688
+ const cart = await ({
7689
+ create: () => shopify.storefront.cartCreate({ ...storefront, lines }),
7690
+ get: () => shopify.storefront.cartGet({ ...storefront, cartId }),
7691
+ linesAdd: () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
7692
+ linesRemove: () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
7693
+ linesUpdate: () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
7694
+ }[operation] || (() => {
7695
+ throw Object.assign(new Error("Unknown cart operation: " + operation), { status: 400 });
7696
+ }))();
7697
+ return { message: "Cart " + operation + ".", request: { cartId: cartId || null, operation }, result: cart };
7698
+ } catch (error) {
7699
+ if (error == null ? void 0 : error.userError) throw Object.assign(error, { status: 400 });
7700
+ throw error;
7701
+ }
7702
+ },
5348
7703
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
5349
7704
  // one lead — which is what lets an order that redeems it be attributed
5350
7705
  // back.
5351
- code: async ({ connection, context, step }, { adminToken, shopify } = {}) => {
7706
+ code: async ({ connection, context, step }, { adminToken, shopify = client } = {}) => {
5352
7707
  var _a;
5353
7708
  const discount = (_a = step.settings) == null ? void 0 : _a.discount;
5354
7709
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
@@ -5394,7 +7749,7 @@ var shopify_default2 = {
5394
7749
  // customer at the store is a support ticket: the context may already
5395
7750
  // carry the id from an earlier step, the lead may already be linked from
5396
7751
  // an earlier run, and Shopify's own get-or-create settles the rest.
5397
- customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => {
7752
+ customer: async ({ connection, context }, { adminToken, read, shopify = client } = {}) => {
5398
7753
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
5399
7754
  if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
5400
7755
  if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
@@ -5444,6 +7799,30 @@ var shopify_default2 = {
5444
7799
  }]
5445
7800
  };
5446
7801
  },
7802
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
7803
+ // rendered rather than stored on the product, because stock is the one
7804
+ // product fact that is stale the instant it is written down.
7805
+ //
7806
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
7807
+ // caller wanted the total; the one that existed summed it inline, and
7808
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
7809
+ // bookkeeping key got counted as a quantity.
7810
+ inventory: async ({ connection, context }, { adminToken, shopify = client } = {}) => {
7811
+ const productId = context == null ? void 0 : context.product;
7812
+ const request2 = { product: productId || null, shop: connection.shop };
7813
+ if (!productId) return { message: "No product id \u2014 nothing to count.", request: request2, result: { total: null }, skipped: true };
7814
+ const adminAccessToken = await adminToken();
7815
+ const byVariant = await shopify.admin.getProductInventory({
7816
+ adminAccessToken,
7817
+ domain: connection.shop,
7818
+ productId
7819
+ });
7820
+ return {
7821
+ message: "Inventory read.",
7822
+ request: request2,
7823
+ result: { total: Object.values(byVariant || {}).reduce((sum, quantity) => sum + quantity, 0) }
7824
+ };
7825
+ },
5447
7826
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
5448
7827
  // attribution genuinely is: an order can reach Drawbridge two ways and
5449
7828
  // they bill differently.
@@ -5764,6 +8143,12 @@ var shopify_default2 = {
5764
8143
  orderId: String(orderId),
5765
8144
  rate,
5766
8145
  shopId: connection.source.id,
8146
+ // WHICH VENDOR IS BEING CHARGED, so the sender does not have
8147
+ // to assume. queue/usage.js named the slug itself and
8148
+ // defaults to it for anything already queued when this
8149
+ // shipped — an app-store vendor billing through its own meter
8150
+ // enqueues the same job with its own slug.
8151
+ slug: connection.slug,
5767
8152
  // The App Events API returns no event id, so one is generated
5768
8153
  // here — the event handle plus the order id — and sent as the
5769
8154
  // event's `reference`. queue/usage.js stamps the same id onto
@@ -5803,10 +8188,28 @@ var shopify_default2 = {
5803
8188
  //
5804
8189
  // The shell has already refused a missing or inactive Shopify connection,
5805
8190
  // so what is left is the two things only this hook can know are wrong.
5806
- product: async ({ connection, context, workflow }, { mintId, read } = {}) => {
8191
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
8192
+ // slots in the closed vocabulary, the same shape inbound.process uses
8193
+ // for its topic table — they are one subject, and a vendor that syncs
8194
+ // products has to answer all three or none.
8195
+ //
8196
+ // record a webhook said a product changed: upsert the row and
8197
+ // queue the pull
8198
+ // sync pull the product from the storefront and write what it
8199
+ // says, including what it says by NOT answering
8200
+ // variants one page of variants, self-chaining
8201
+ //
8202
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
8203
+ // queue/product.js — a shadow implementation of the step this manifest
8204
+ // already declared, importing three of the vendor's client modules from
8205
+ // a worker file.
8206
+ product: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {}) => {
8207
+ const operation = (context == null ? void 0 : context.operation) || "record";
8208
+ if (operation === "sync") return syncProduct({ adminToken, connection, context, read, settings, shopify });
8209
+ if (operation === "variants") return syncVariants({ connection, context, read, settings, shopify });
5807
8210
  const request2 = {
5808
8211
  numericId: (context == null ? void 0 : context.id) || null,
5809
- organizationId: workflow.organization,
8212
+ organizationId: (workflow == null ? void 0 : workflow.organization) || null,
5810
8213
  title: (context == null ? void 0 : context.title) || null
5811
8214
  };
5812
8215
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
@@ -5856,9 +8259,612 @@ var shopify_default2 = {
5856
8259
  }
5857
8260
  },
5858
8261
  contacts: { remove: false, sync: false },
5859
- // The embedded-app surface. Only a vendor whose merchants arrive from its
5860
- // own app store has one.
5861
- install: false,
8262
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
8263
+ // own app store has one, and these thirteen slots are the thirteen routes
8264
+ // that used to live in api/route/shopify-embedded.js.
8265
+ //
8266
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
8267
+ // holds a controller — `read` is the controller's read methods and nothing
8268
+ // else. Where one REFUSES it throws with a status, and the route answers
8269
+ // what it said. The helpers they share are above the manifest.
8270
+ install: {
8271
+ account: {
8272
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
8273
+ // authenticates the merchant once, top-level, and stashes their user
8274
+ // token in `install.token.save`; App Bridge has already proven shop
8275
+ // control by the time this runs.
8276
+ //
8277
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
8278
+ // token: the presented one must BE this shop's one-shot handoff token,
8279
+ // unexpired, and an access or pat record — a refresh or oauth-state
8280
+ // token that happens to resolve to a user must not qualify, which is
8281
+ // the rule a session is held to in drawbridge-api's middleware.
8282
+ link: async ({ context }, { read } = {}) => {
8283
+ var _a;
8284
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8285
+ const token = context == null ? void 0 : context.token;
8286
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8287
+ const now = /* @__PURE__ */ new Date();
8288
+ const record = await read.get({ collection: "shop", query: { shop } });
8289
+ const stashed = (record == null ? void 0 : record.linkToken) ? (_a = decrypt(record.linkToken)) == null ? void 0 : _a.token : null;
8290
+ const expired = !(record == null ? void 0 : record.linkTokenExpiresAt) || new Date(record.linkTokenExpiresAt) <= now;
8291
+ if (!stashed || expired || stashed !== token) throw refusal("Invalid or expired link token", 401);
8292
+ const granted = await read.get({
8293
+ collection: "token",
8294
+ query: {
8295
+ revoked: false,
8296
+ tokenHash: hashToken(token),
8297
+ type: { $in: [tokenTypes.access, tokenTypes.pat] }
8298
+ }
8299
+ });
8300
+ if (!(granted == null ? void 0 : granted.user) || granted.expiresAt && new Date(granted.expiresAt) <= now) {
8301
+ throw refusal("Invalid or expired link token", 401);
8302
+ }
8303
+ const switching = Boolean(record == null ? void 0 : record.user) && record.user !== granted.user;
8304
+ const connections2 = switching ? await read.aggregate({
8305
+ collection: "connection",
8306
+ pipeline: [
8307
+ { $match: { shop, slug: "shopify" } },
8308
+ { $project: { _id: 0, id: 1 } }
8309
+ ]
8310
+ }) : [];
8311
+ return {
8312
+ message: switching ? "Linked to a different Drawbridge account." : "Linked.",
8313
+ request: { shop },
8314
+ result: { connected: true, switched: switching },
8315
+ // ALL OR NONE. A half-done switch is a store linked to the new
8316
+ // account while the old account's connections still serve from it.
8317
+ transaction: true,
8318
+ writes: [
8319
+ ...(connections2 || []).map((connection) => ({
8320
+ collection: "connection",
8321
+ operation: "delete",
8322
+ query: { id: connection.id }
8323
+ })),
8324
+ {
8325
+ collection: "shop",
8326
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: granted.user } },
8327
+ operation: "update",
8328
+ query: { shop }
8329
+ }
8330
+ ]
8331
+ };
8332
+ },
8333
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
8334
+ // the durable link clears; the offline token and the shop record stay,
8335
+ // because the app is still installed and the merchant may link a
8336
+ // different account next.
8337
+ unlink: async ({ context }, { read } = {}) => {
8338
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8339
+ if (!shop) throw refusal("shop is required", 400);
8340
+ const connections2 = await read.aggregate({
8341
+ collection: "connection",
8342
+ pipeline: [
8343
+ { $match: { shop, slug: "shopify" } },
8344
+ { $project: { _id: 0, id: 1 } }
8345
+ ]
8346
+ });
8347
+ return {
8348
+ message: "Unlinked " + (connections2 || []).length + " connection(s).",
8349
+ request: { shop },
8350
+ result: { ok: true },
8351
+ transaction: true,
8352
+ writes: [
8353
+ ...(connections2 || []).map((connection) => ({
8354
+ collection: "connection",
8355
+ operation: "delete",
8356
+ query: { id: connection.id }
8357
+ })),
8358
+ {
8359
+ collection: "shop",
8360
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: null } },
8361
+ operation: "update",
8362
+ query: { shop }
8363
+ }
8364
+ ]
8365
+ };
8366
+ }
8367
+ },
8368
+ organizations: {
8369
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
8370
+ // gate in it came from a line in the route it replaces.
8371
+ add: async ({ context }, { currencies, mintId, read, shopify = client } = {}) => {
8372
+ var _a, _b;
8373
+ const organization = context == null ? void 0 : context.organization;
8374
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8375
+ if (!shop) throw refusal("shop is required", 400);
8376
+ if (!organization) throw refusal("organization is required", 400);
8377
+ const record = await read.get({ collection: "shop", query: { shop } });
8378
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8379
+ const owned = await merchantOrganizations({ read, user: record.user });
8380
+ const org = (owned || []).find((entry) => entry.id === organization);
8381
+ if (!org) throw refusal("No access to this organization", 403);
8382
+ if (!CONNECTABLE_STATUSES.includes(org.billingStatus)) {
8383
+ throw refusal("This organization can't accept connections right now \u2014 check its billing status in the dashboard", 403);
8384
+ }
8385
+ if (org.connection && org.connection.shop !== shop) {
8386
+ throw refusal("This organization is already connected to another Shopify store", 409);
8387
+ }
8388
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
8389
+ if (!(settings == null ? void 0 : settings.accessToken)) {
8390
+ throw refusal("Store is not installed \u2014 open the app in the Shopify admin first", 409);
8391
+ }
8392
+ const plan = await storePlan({ record, shopify });
8393
+ if (plan.unavailable) throw refusal("Couldn't verify the store's plan \u2014 try again shortly", 502);
8394
+ if (!plan.active) throw refusal("Approve a plan before connecting organizations", 402);
8395
+ let storefront = null;
8396
+ if (!record.storefront) {
8397
+ try {
8398
+ const minted2 = await shopify.oauth.createStorefrontToken({
8399
+ adminAccessToken: settings.accessToken,
8400
+ shop
8401
+ });
8402
+ if (minted2) storefront = encrypt({ token: minted2 });
8403
+ } catch (_) {
8404
+ }
8405
+ }
8406
+ let currency = (record == null ? void 0 : record.currency) || null;
8407
+ let source = (record == null ? void 0 : record.source) || null;
8408
+ try {
8409
+ const shopData = await shopify.oauth.getShop({ adminAccessToken: settings.accessToken, shop });
8410
+ if (shopData) {
8411
+ currency = String(shopData.currency || "").toLowerCase() || null;
8412
+ source = { domain: shopData.myshopifyDomain, id: String(shopData.id), label: shopData.name };
8413
+ }
8414
+ } catch (_) {
8415
+ }
8416
+ if (currency && currencies && !currencies.includes(currency)) {
8417
+ throw refusal("This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.", 422);
8418
+ }
8419
+ const minted = ((_a = org.connection) == null ? void 0 : _a.id) ? null : mintId();
8420
+ const connectionId = ((_b = org.connection) == null ? void 0 : _b.id) || minted.id;
8421
+ return {
8422
+ message: "Connected " + org.name + " to " + shop + ".",
8423
+ request: { organization, shop },
8424
+ result: { ok: true },
8425
+ transaction: true,
8426
+ writes: [
8427
+ ...storefront ? [{
8428
+ collection: "shop",
8429
+ data: { $set: { storefront } },
8430
+ operation: "update",
8431
+ query: { shop }
8432
+ }] : [],
8433
+ {
8434
+ collection: "connection",
8435
+ data: {
8436
+ $set: {
8437
+ currency,
8438
+ errors: [],
8439
+ // The token of record lives on the shared `shop` row; this
8440
+ // is the pointer that satisfies the required `settings`
8441
+ // string, and `auth.install.shared` declares how to read it.
8442
+ settings: encrypt({ ref: "shop", shop }),
8443
+ shop,
8444
+ status: "pending",
8445
+ ...source && { source }
8446
+ },
8447
+ $setOnInsert: {
8448
+ ...minted && { _id: minted._id, id: minted.id },
8449
+ feature: "organization:connection:shopify",
8450
+ organization,
8451
+ slug: "shopify"
8452
+ }
8453
+ },
8454
+ operation: "update",
8455
+ options: { upsert: true },
8456
+ query: { organization, slug: "shopify" }
8457
+ },
8458
+ {
8459
+ collection: "organization",
8460
+ data: {
8461
+ // $addToSet UNCONDITIONALLY. The route this replaces only
8462
+ // registered the refs when the upsert INSERTED, which meant an
8463
+ // org whose refs had drifted stayed drifted through every
8464
+ // re-link. Adding a member that is already there is a no-op,
8465
+ // so doing it every time costs nothing and repairs the drift.
8466
+ $addToSet: {
8467
+ "connections.groups": "ecommerce",
8468
+ "connections.ids": connectionId,
8469
+ "connections.keys": "shopify"
8470
+ },
8471
+ $set: { billingProvider: "shopify" }
8472
+ },
8473
+ operation: "update",
8474
+ query: { id: organization }
8475
+ }
8476
+ ]
8477
+ };
8478
+ },
8479
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
8480
+ // store's live plan, and the products the merchant has to act on.
8481
+ //
8482
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
8483
+ // `add` would refuse it — a list that offers what the next call rejects
8484
+ // is worse than a shorter one.
8485
+ list: async ({ context }, { conversionRate, planTitle, read, shopify = client } = {}) => {
8486
+ var _a;
8487
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8488
+ if (!shop) throw refusal("shop is required", 400);
8489
+ const record = await read.get({ collection: "shop", query: { shop } });
8490
+ if (!(record == null ? void 0 : record.user)) {
8491
+ return {
8492
+ message: "No account linked to this store.",
8493
+ request: { shop },
8494
+ result: {
8495
+ account: null,
8496
+ connected: false,
8497
+ organizations: [],
8498
+ plan: { active: false, name: null },
8499
+ unavailableProducts: [],
8500
+ unavailableProductsTotal: 0
8501
+ }
8502
+ };
8503
+ }
8504
+ const owned = await merchantOrganizations({ read, user: record.user });
8505
+ const organizations = (owned || []).filter((org) => !org.connection || org.connection.shop === shop).map((org) => {
8506
+ var _a2;
8507
+ return {
8508
+ // The org's OWN billing status decides whether `add` accepts it.
8509
+ // Sent up so the app can disable Connect and name the reason
8510
+ // rather than letting the merchant click into a 403 —
8511
+ // listed-but-blocked beats hidden, because an org vanishing from
8512
+ // the list is indistinguishable from a bug.
8513
+ billingStatus: org.billingStatus || null,
8514
+ connectable: CONNECTABLE_STATUSES.includes(org.billingStatus),
8515
+ connected: Boolean(org.connection),
8516
+ conversion: conversionRate(org.subscription),
8517
+ id: org.id,
8518
+ name: org.name,
8519
+ plan: planTitle(org.subscription),
8520
+ status: ((_a2 = org.connection) == null ? void 0 : _a2.status) || null
8521
+ };
8522
+ });
8523
+ const connections2 = (owned || []).filter((org) => {
8524
+ var _a2;
8525
+ return ((_a2 = org.connection) == null ? void 0 : _a2.shop) === shop && org.connection.id;
8526
+ }).map((org) => org.connection.id);
8527
+ const [account, plan, unavailable] = await Promise.all([
8528
+ read.get({ collection: "user", query: { id: record.user } }),
8529
+ storePlan({ record, shopify }),
8530
+ connections2.length ? read.aggregate({
8531
+ collection: "product",
8532
+ pipeline: [
8533
+ { $match: { connections: { $in: connections2 }, "source.domain": shop, status: "inactive" } },
8534
+ // $facet so the app can name the first 25 AND report a
8535
+ // truthful total: listing 25 of 40 under "25 products need
8536
+ // attention" would understate the problem.
8537
+ {
8538
+ $facet: {
8539
+ items: [{ $sort: { title: 1 } }, { $limit: 25 }, { $project: { _id: 0, id: 1, title: 1 } }],
8540
+ total: [{ $count: "count" }]
8541
+ }
8542
+ }
8543
+ ]
8544
+ }) : []
8545
+ ]);
8546
+ const facet = (unavailable || [])[0] || {};
8547
+ if (plan.active && plan.metered !== true) {
8548
+ const stamped = await read.aggregate({
8549
+ collection: "connection",
8550
+ pipeline: [
8551
+ { $match: { shop, slug: "shopify" } },
8552
+ { $project: { _id: 0, "source.metered": 1 } }
8553
+ ]
8554
+ });
8555
+ const verdicts = (stamped || []).map((row2) => {
8556
+ var _a2;
8557
+ return (_a2 = row2 == null ? void 0 : row2.source) == null ? void 0 : _a2.metered;
8558
+ });
8559
+ if (verdicts.some((verdict) => typeof verdict === "string")) plan.metered = true;
8560
+ else if (verdicts.some((verdict) => verdict === null)) plan.metered = false;
8561
+ }
8562
+ return {
8563
+ message: organizations.length + " organization(s) for this store.",
8564
+ request: { shop },
8565
+ result: {
8566
+ account: { email: (account == null ? void 0 : account.email) || null },
8567
+ connected: true,
8568
+ organizations,
8569
+ plan,
8570
+ unavailableProducts: (facet.items || []).map((product) => ({
8571
+ id: product.id,
8572
+ title: product.title || "Untitled product"
8573
+ })),
8574
+ // The total can exceed the 25 listed above, and has to.
8575
+ unavailableProductsTotal: ((_a = (facet.total || [])[0]) == null ? void 0 : _a.count) || 0
8576
+ }
8577
+ };
8578
+ },
8579
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
8580
+ // else: every dependent — products, ads, steps, workflows, and the org's
8581
+ // own connection refs — is torn down by drawbridge-sync's connection
8582
+ // change stream. The shared token and the other orgs are untouched.
8583
+ remove: async ({ context }, { read } = {}) => {
8584
+ const organization = context == null ? void 0 : context.organization;
8585
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8586
+ if (!shop) throw refusal("shop is required", 400);
8587
+ if (!organization) throw refusal("organization is required", 400);
8588
+ const record = await read.get({ collection: "shop", query: { shop } });
8589
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8590
+ const owned = await merchantOrganizations({ read, user: record.user });
8591
+ if (!(owned || []).some((entry) => entry.id === organization)) {
8592
+ throw refusal("No access to this organization", 403);
8593
+ }
8594
+ const connection = await read.get({
8595
+ collection: "connection",
8596
+ query: { organization, shop, slug: "shopify" }
8597
+ });
8598
+ if (!connection) throw refusal("No connection to remove", 404);
8599
+ return {
8600
+ message: "Disconnected.",
8601
+ request: { organization, shop },
8602
+ result: { ok: true },
8603
+ writes: [{ collection: "connection", operation: "delete", query: { id: connection.id } }]
8604
+ };
8605
+ }
8606
+ },
8607
+ plan: {
8608
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
8609
+ // and falls back to "Choose a plan".
8610
+ cancel: ({ context }, { read, shopify = client } = {}) => releaseApprovals({ context, read, shopify }),
8611
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
8612
+ // order step and run on a retrying queue, so this may be called
8613
+ // several times for one order — which is safe because the idempotency
8614
+ // key is PERMANENT for billing events and Shopify settles the repeat.
8615
+ //
8616
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
8617
+ // is a real strengthening of what it replaced: drawbridge-sync compared
8618
+ // the job's `handle` against the job's `transaction` — two fields from
8619
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
8620
+ // handle is the one string that decides whether the event bills at all,
8621
+ // and this is the file that publishes it.
8622
+ //
8623
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
8624
+ // idempotency key on an event that misclassifies or mistraces. The
8625
+ // throw rides the queue's normal retry path and the abandoned-job alert
8626
+ // pages on exhaustion.
8627
+ //
8628
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
8629
+ // which is always inside the merchant's current billing cycle at the
8630
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
8631
+ // purchase time reads more correct and is strictly worse: a retry
8632
+ // backoff crossing a cycle boundary would carry a closed-cycle
8633
+ // timestamp, Shopify would 202-accept, silently drop it, and the
8634
+ // commission would be lost. Send-time's worst case is benign — the
8635
+ // charge lands in the next cycle, but it lands.
8636
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify = client } = {}) => {
8637
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
8638
+ const handle = (context == null ? void 0 : context.handle) || manifest.auth.install.billing.handle;
8639
+ if (!transaction || !String(transaction).startsWith(handle + ".")) {
8640
+ throw new Error("shopify.usage.billing.handle.mismatch on order " + orderId + ": transaction " + (transaction || "null") + " does not carry handle " + handle + " \u2014 refusing to send");
8641
+ }
8642
+ await shopify.billing.sendAppEvent({
8643
+ clientId,
8644
+ clientSecret,
8645
+ eventHandle: handle,
8646
+ idempotencyKey,
8647
+ // The Shopify-side event record names the order that produced it.
8648
+ reference: transaction,
8649
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
8650
+ // send. `revision` suffixes the idempotency key, which is what
8651
+ // makes a second attempt possible once a key has been consumed;
8652
+ // the encoding is owned by sendAppEvent so no caller builds keys
8653
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
8654
+ // path must never do — see the note above — and the recovery
8655
+ // migration must, because it is resending orders from a closed
8656
+ // window and bounds its own run to the merchant's current cycle.
8657
+ ...revision && { revision },
8658
+ ...timestamp && { timestamp },
8659
+ shopId,
8660
+ value
8661
+ });
8662
+ return {
8663
+ message: "Usage charge sent for order " + orderId + ".",
8664
+ request: { order: order || null, orderId: orderId || null, shopId: shopId || null, value },
8665
+ result: { transaction: String(transaction) },
8666
+ // TRACEABILITY. The App Events API returns no event id, so the
8667
+ // trace key into Shopify's event logs is the transaction id the
8668
+ // commerce.order hook minted and this sent as `reference`.
8669
+ // `amount` is the per-order usage value in cents, mirroring the
8670
+ // Stripe path's marker.
8671
+ ...order && { writes: [{
8672
+ collection: "order",
8673
+ data: {
8674
+ $set: {
8675
+ billed: {
8676
+ amount: value,
8677
+ date: /* @__PURE__ */ new Date(),
8678
+ rate: rate || 0,
8679
+ transaction: String(transaction)
8680
+ }
8681
+ }
8682
+ },
8683
+ operation: "update",
8684
+ query: { id: order }
8685
+ }] }
8686
+ };
8687
+ },
8688
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
8689
+ // be released before the pricing page will offer the plan again — but
8690
+ // the intent is a thirty-second round trip rather than a departure.
8691
+ //
8692
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
8693
+ // needs one. The marker has to be persisted BEFORE the approval is
8694
+ // released: it is what tells sync's CANCELLED handler to hold the pause
8695
+ // — no connection error, no owner email — and start a timed recheck that
8696
+ // pauses honestly if the merchant abandons. A described write lands
8697
+ // after the hook returns, which is after the cancel, and a marker
8698
+ // written then can race the very webhook it exists to soften. Same
8699
+ // precedent as `resolveContact`: the one write a description cannot
8700
+ // carry.
8701
+ reselect: async ({ context }, { mark, read, shopify = client } = {}) => {
8702
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8703
+ if (!shop) throw refusal("shop is required", 400);
8704
+ await mark({ shop });
8705
+ return releaseApprovals({ context, read, shopify });
8706
+ },
8707
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
8708
+ // action — the other two slots here are — but the same subject, and
8709
+ // the reason it is a declared hook rather than a helper is that the
8710
+ // answer was being derived in two places: `lifecycle.health` below,
8711
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
8712
+ // stated in its own comment that it matched this one and did not.
8713
+ //
8714
+ // THREE ANSWERS, and the caller must keep them apart:
8715
+ //
8716
+ // a string PROVEN. The charge id or usage line id, stamped on
8717
+ // the connection as the evidence.
8718
+ // null PROBED, and nothing is accruing.
8719
+ // undefined NO VERDICT — the probe could not run. `probed` says
8720
+ // which, because undefined does not survive JSON.
8721
+ verify: async ({ context }, { adminToken, read, shopify = client } = {}) => {
8722
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8723
+ if (!shop) throw refusal("shop is required", 400);
8724
+ const adminAccessToken = await adminToken();
8725
+ if (!adminAccessToken) {
8726
+ return { message: "No admin token for this store \u2014 no verdict.", request: { shop }, result: { metered: null, probed: false }, skipped: true };
8727
+ }
8728
+ const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
8729
+ const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
8730
+ const { ledger, metered } = await meterVerdict({
8731
+ adminAccessToken,
8732
+ ledgerFloor: new Date(Date.now() - LEDGER_WINDOW_MS),
8733
+ partner,
8734
+ shop,
8735
+ shopId: (context == null ? void 0 : context.shopId) || null,
8736
+ shopify
8737
+ });
8738
+ return {
8739
+ message: typeof metered === "string" ? "Billing is metered." : "No meter evidence for this store.",
8740
+ request: { shop },
8741
+ // `probed` is the LEDGER, not the call: a store whose Partner
8742
+ // credentials are absent was not asked, and reporting that as
8743
+ // "probed, nothing accruing" would stamp an unmetered verdict on
8744
+ // a store nobody looked at.
8745
+ result: { metered: metered ?? null, probed: ledger || typeof metered === "string" }
8746
+ };
8747
+ }
8748
+ },
8749
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
8750
+ // persists the offline session on token exchange and refresh; it lives
8751
+ // encrypted on the shared `shop` record, which is the same row every
8752
+ // organization's connection to this store points at.
8753
+ session: {
8754
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
8755
+ // invalidation and not only on uninstall, so hard-deleting the record
8756
+ // would orphan every org connection pointing at it — token resolution
8757
+ // silently nulls — and destroy the durable account link and the
8758
+ // storefront token with it. The next embedded load re-runs token
8759
+ // exchange. The full record delete belongs to the uninstall webhook.
8760
+ clear: async ({ context }) => {
8761
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8762
+ if (!shop) throw refusal("shop is required", 400);
8763
+ return {
8764
+ message: "Session cleared.",
8765
+ request: { shop },
8766
+ result: { ok: true },
8767
+ writes: [{
8768
+ collection: "shop",
8769
+ data: { $set: { settings: null } },
8770
+ operation: "update",
8771
+ query: { shop }
8772
+ }]
8773
+ };
8774
+ },
8775
+ read: async ({ context }, { read } = {}) => {
8776
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8777
+ if (!shop) throw refusal("shop is required", 400);
8778
+ const record = await read.get({ collection: "shop", query: { shop } });
8779
+ if (!(record == null ? void 0 : record.settings)) throw refusal("No session for this store", 404);
8780
+ return {
8781
+ message: "Session read.",
8782
+ request: { shop },
8783
+ result: { session: decrypt(record.settings) }
8784
+ };
8785
+ },
8786
+ save: async ({ context }) => {
8787
+ const session = context == null ? void 0 : context.session;
8788
+ const shop = sanitizeDomain(session == null ? void 0 : session.shop);
8789
+ if (!shop || !(session == null ? void 0 : session.accessToken)) throw refusal("session.shop and session.accessToken are required", 400);
8790
+ return {
8791
+ message: "Session saved.",
8792
+ request: { shop },
8793
+ result: { ok: true },
8794
+ writes: [{
8795
+ collection: "shop",
8796
+ data: {
8797
+ $set: { settings: encrypt(session), shop },
8798
+ $setOnInsert: { status: "pending" }
8799
+ },
8800
+ operation: "update",
8801
+ options: { upsert: true },
8802
+ query: { shop }
8803
+ }]
8804
+ };
8805
+ }
8806
+ },
8807
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
8808
+ // a first-party cookie; the embedded App Home cannot read that cookie from
8809
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
8810
+ // user token here keyed by shop, and App Home reads it once and clears it.
8811
+ //
8812
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
8813
+ // mid-handoff. Short-lived and encrypted.
8814
+ token: {
8815
+ clear: async ({ context }) => {
8816
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8817
+ if (!shop) throw refusal("shop is required", 400);
8818
+ return {
8819
+ message: "Link token cleared.",
8820
+ request: { shop },
8821
+ result: { ok: true },
8822
+ writes: [{
8823
+ collection: "shop",
8824
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null } },
8825
+ operation: "update",
8826
+ query: { shop }
8827
+ }]
8828
+ };
8829
+ },
8830
+ read: async ({ context }, { read } = {}) => {
8831
+ var _a;
8832
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8833
+ if (!shop) throw refusal("shop is required", 400);
8834
+ const record = await read.get({ collection: "shop", query: { shop } });
8835
+ const expiresAt = (record == null ? void 0 : record.linkTokenExpiresAt) ? new Date(record.linkTokenExpiresAt) : null;
8836
+ if (!(record == null ? void 0 : record.linkToken) || !expiresAt || expiresAt <= /* @__PURE__ */ new Date()) {
8837
+ throw refusal("No link token for this store", 404);
8838
+ }
8839
+ return {
8840
+ message: "Link token read.",
8841
+ request: { shop },
8842
+ result: { token: ((_a = decrypt(record.linkToken)) == null ? void 0 : _a.token) ?? null }
8843
+ };
8844
+ },
8845
+ save: async ({ context }) => {
8846
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8847
+ const token = context == null ? void 0 : context.token;
8848
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8849
+ return {
8850
+ message: "Link token saved.",
8851
+ request: { shop },
8852
+ result: { ok: true },
8853
+ writes: [{
8854
+ collection: "shop",
8855
+ data: {
8856
+ $set: {
8857
+ linkToken: encrypt({ token }),
8858
+ linkTokenExpiresAt: new Date(Date.now() + LINK_TOKEN_TTL_MS)
8859
+ }
8860
+ },
8861
+ operation: "update",
8862
+ query: { shop }
8863
+ }]
8864
+ };
8865
+ }
8866
+ }
8867
+ },
5862
8868
  // Drawbridge sends its own notification email and SMS — see the private
5863
8869
  // `drawbridge` manifest. A vendor answering these would be a second sender,
5864
8870
  // which is the arrangement the platform sender replaced.
@@ -5920,8 +8926,8 @@ var shopify_default2 = {
5920
8926
  // access token still works, reconciles the scopes the store granted
5921
8927
  // against the ones the app now needs, and queues a webhook
5922
8928
  // reconciliation.
5923
- health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
5924
- var _a, _b, _c, _d, _e, _f;
8929
+ health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {}) => {
8930
+ var _a, _b, _c, _d;
5925
8931
  const request2 = {
5926
8932
  connectionId: workflow.connection,
5927
8933
  organizationId: workflow.organization,
@@ -5958,33 +8964,26 @@ var shopify_default2 = {
5958
8964
  shopCurrency = String(shopData.currency || "").toLowerCase() || null;
5959
8965
  sourceRepaired = true;
5960
8966
  }
5961
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
5962
- adminAccessToken,
5963
- domain: connection.shop
5964
- });
5965
- const usageLine = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
5966
- if (usageLine) metered = usageLine;
5967
8967
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
5968
8968
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
5969
- if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
5970
- const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
5971
- const accruals = await shopify.partner.getUsageChargeEvents({
5972
- appId: "gid://shopify/App/" + partner.partnerAppId,
5973
- first: 10,
5974
- occurredAtMin: ledgerFloor.toISOString(),
5975
- organizationId: partner.partnerOrgId,
5976
- partnerToken: partner.partnerToken,
5977
- shopId: "gid://shopify/Shop/" + source.id
5978
- });
5979
- const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
5980
- const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
5981
- if (accrued) metered = String(accrued.chargeId || accrued.id);
5982
- const stampedAt = (_c = boundaries.find(
8969
+ const ledgerFloor = new Date(Date.now() - LEDGER_WINDOW_MS);
8970
+ const verdict = await meterVerdict({
8971
+ adminAccessToken,
8972
+ ledgerFloor,
8973
+ partner,
8974
+ shop: connection.shop,
8975
+ shopId: source == null ? void 0 : source.id,
8976
+ shopify
8977
+ });
8978
+ if (verdict.metered !== void 0) metered = verdict.metered;
8979
+ const { boundaries } = verdict;
8980
+ if (boundaries.length) {
8981
+ const stampedAt = (_a = boundaries.find(
5983
8982
  (event) => {
5984
8983
  var _a2;
5985
8984
  return String(event.chargeId || event.id) === String(((_a2 = connection.source) == null ? void 0 : _a2.metered) || "");
5986
8985
  }
5987
- )) == null ? void 0 : _c.occurredAt;
8986
+ )) == null ? void 0 : _a.occurredAt;
5988
8987
  reconciliation = [];
5989
8988
  let windowStart = ledgerFloor;
5990
8989
  for (const boundary of boundaries) {
@@ -6034,7 +9033,7 @@ var shopify_default2 = {
6034
9033
  }
6035
9034
  });
6036
9035
  } else {
6037
- (_d = logger == null ? void 0 : logger.error) == null ? void 0 : _d.call(logger, new Error(
9036
+ (_b = logger == null ? void 0 : logger.error) == null ? void 0 : _b.call(logger, new Error(
6038
9037
  "shopify.usage.reconciliation.mismatch on shop " + connection.shop + ": charge " + charge + " charged " + charged + " cents but its window holds " + billed + " cents across " + orders.length + " billed orders \u2014 each cent of difference is commission that never charged"
6039
9038
  ), {
6040
9039
  extra: {
@@ -6053,13 +9052,13 @@ var shopify_default2 = {
6053
9052
  }
6054
9053
  } catch (probeError) {
6055
9054
  billingProbeFailed = true;
6056
- (_e = logger == null ? void 0 : logger.warn) == null ? void 0 : _e.call(logger, "shopify.health.billing.probe.failed", {
9055
+ (_c = logger == null ? void 0 : logger.warn) == null ? void 0 : _c.call(logger, "shopify.health.billing.probe.failed", {
6057
9056
  connectionId: connection.id,
6058
9057
  message: probeError == null ? void 0 : probeError.message,
6059
9058
  shop: connection.shop
6060
9059
  });
6061
9060
  }
6062
- if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection.source) == null ? void 0 : _f.metered) ?? void 0)) {
9061
+ if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection.source) == null ? void 0 : _d.metered) ?? void 0)) {
6063
9062
  writes.push({
6064
9063
  collection: "connection",
6065
9064
  data: {
@@ -6194,13 +9193,13 @@ var shopify_default2 = {
6194
9193
  // They are the same two questions every other vendor answers through
6195
9194
  // resources.*, so they answer them the same way now.
6196
9195
  //
6197
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
9196
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
6198
9197
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
6199
9198
  // and nothing else — no controller, no collection access. Resolving the
6200
9199
  // credential is the caller's job because it is Drawbridge's job: the
6201
9200
  // admin token refreshes and writes itself back, which is service work,
6202
9201
  // not vendor work.
6203
- products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
9202
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify = client } = {}) => {
6204
9203
  var _a, _b, _c, _d;
6205
9204
  const products = await shopify.storefront.getProducts({
6206
9205
  cursor,
@@ -6222,7 +9221,7 @@ var shopify_default2 = {
6222
9221
  }
6223
9222
  };
6224
9223
  },
6225
- promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
9224
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify = client } = {}) => {
6226
9225
  var _a, _b;
6227
9226
  const discounts = await shopify.admin.getDiscounts({
6228
9227
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -6373,6 +9372,13 @@ var shopify_default2 = {
6373
9372
  key: "Shopify Order Tracking",
6374
9373
  queue: "connection",
6375
9374
  system: true,
9375
+ // THE TRIGGER THE REGISTER JOB HAND-WROTE. It is byte-identical to
9376
+ // the one on every stored workflow, because these documents exist on
9377
+ // prod with thousands of runs behind them and provisioning is
9378
+ // idempotent on (connection, system, title) — a different trigger
9379
+ // here would not be picked up by the existing rows anyway, and a
9380
+ // different TITLE would provision a second workflow for every store.
9381
+ trigger: { event: "shopify.order.create", type: "webhook" },
6376
9382
  type: "step.commerce.order.record"
6377
9383
  })
6378
9384
  },
@@ -6383,6 +9389,15 @@ var shopify_default2 = {
6383
9389
  key: "Shopify Product Sync",
6384
9390
  queue: "connection",
6385
9391
  system: true,
9392
+ // `shopify.product.update`, NOT `product.insert`. The plan named the
9393
+ // latter, and it is the right long-term answer — a Drawbridge product
9394
+ // row appearing should pull the vendor's copy, rather than
9395
+ // stream/product.js branching on the slug and enqueueing a
9396
+ // vendor-named queue. But a workflow carries ONE trigger, this one is
9397
+ // stored on prod against the webhook, and changing a live workflow's
9398
+ // trigger is a data migration. So the declaration matches what exists
9399
+ // and `product.insert` stays a separate, migration-gated change.
9400
+ trigger: { event: "shopify.product.update", type: "webhook" },
6386
9401
  type: "step.commerce.product.sync"
6387
9402
  })
6388
9403
  }
@@ -6404,26 +9419,37 @@ var shopify_default2 = {
6404
9419
  type: "step.connection.health.check"
6405
9420
  })
6406
9421
  },
6407
- // Audit-only. The "Shopify Token Activity" system workflow lists these
6408
- // for descriptive grouping, but its audit step docs are written manually
6409
- // at OAuth time — the workflow is never dispatched. Routing is declared
6410
- // defensively so that if it ever IS dispatched, the job lands on a real
6411
- // queue and the handler lookup misses cleanly instead of throwing
6412
- // "Unknown step type".
9422
+ // THE ONE WORKFLOW THAT CARRIES TWO STEPS, and the shape the plan called
9423
+ // the single genuinely new thing in it.
9424
+ //
9425
+ // Every other system step is its own workflow, titled by its own `key`.
9426
+ // These two are "Shopify Token Activity", one row in the merchant's list
9427
+ // with both steps in it — so they declare `workflow`, which names the
9428
+ // title, the trigger and the ORDER, and the provisioner groups on it.
9429
+ // Without that key the pair could only be provisioned by the hand-written
9430
+ // spec in drawbridge-sync's register job, which is exactly why that spec
9431
+ // outlived every other one.
9432
+ //
9433
+ // AUDIT-ONLY. The step documents are written manually at OAuth time and
9434
+ // the workflow is never dispatched; `queue` is declared defensively so
9435
+ // that if it ever IS, the job lands on a real queue and the handler lookup
9436
+ // misses cleanly rather than throwing "Unknown step type".
6413
9437
  token: {
6414
9438
  exchange: () => ({
6415
9439
  description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
6416
9440
  key: "Shopify Token Exchange",
6417
9441
  queue: "connection",
6418
9442
  system: true,
6419
- type: "step.connection.token.exchange"
9443
+ type: "step.connection.token.exchange",
9444
+ workflow: { key: "Shopify Token Activity", order: 0, trigger: { event: "shopify.token", type: "event" } }
6420
9445
  }),
6421
9446
  refresh: () => ({
6422
9447
  description: "Records a token rotation. Audit only \u2014 never dispatched.",
6423
9448
  key: "Shopify Token Refresh",
6424
9449
  queue: "connection",
6425
9450
  system: true,
6426
- type: "step.connection.token.refresh"
9451
+ type: "step.connection.token.refresh",
9452
+ workflow: { key: "Shopify Token Activity", order: 1, trigger: { event: "shopify.token", type: "event" } }
6427
9453
  })
6428
9454
  }
6429
9455
  }
@@ -6509,7 +9535,7 @@ var shopify_default2 = {
6509
9535
  };
6510
9536
 
6511
9537
  // lib/connections/manifests/webhook.js
6512
- import crypto2 from "crypto";
9538
+ import crypto3 from "crypto";
6513
9539
 
6514
9540
  // lib/safe-http.js
6515
9541
  import dns2 from "dns";
@@ -6682,7 +9708,7 @@ var signature = ({ body, settings }) => {
6682
9708
  return [
6683
9709
  "t=" + timestamp,
6684
9710
  ...[settings.secret, ...previous].map(
6685
- (secret) => "v1=" + crypto2.createHmac(generated.algorithm, secret).update(payload).digest(generated.encoding)
9711
+ (secret) => "v1=" + crypto3.createHmac(generated.algorithm, secret).update(payload).digest(generated.encoding)
6686
9712
  )
6687
9713
  ].join(",");
6688
9714
  };
@@ -6727,14 +9753,15 @@ var webhook_default = {
6727
9753
  "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
6728
9754
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
6729
9755
  ],
6730
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6731
- // The dashboard already carries generic reasons for every way an
6732
- // authorization can fail declined, expired state, a vendor we are not set
6733
- // up for and a sentence here is an ADDITION to those, for a vendor whose
6734
- // failures need saying differently. This one's do not.
9756
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
9757
+ // nothing is authorized against a third party, so neither `denied` nor
9758
+ // `invalid` has a state it could describe. The failures this connection
9759
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
9760
+ // those reach them through the connection's own errors rather than through
9761
+ // connect copy.
6735
9762
  //
6736
- // Answered rather than omitted, because `errors` existed on two of six
6737
- // manifests and there was no way to tell "nothing to add" from "nobody
9763
+ // `false` is the recorded decision, not silence: `errors` existed on two of
9764
+ // six manifests and there was no way to tell "nothing to add" from "nobody
6738
9765
  // wrote it".
6739
9766
  errors: false,
6740
9767
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -6878,7 +9905,7 @@ var webhook_default = {
6878
9905
  // The connect prompt only where connecting is not already the card's whole
6879
9906
  // story: a disconnected or errored document renders a Connect action itself,
6880
9907
  // and a task repeating it talks over the button.
6881
- tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
9908
+ tasks: ({ settings, status } = {}) => status === "error" ? [] : (settings == null ? void 0 : settings.secret) ? [
6882
9909
  {
6883
9910
  message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
6884
9911
  title: "Requests must be verified",
@@ -6965,6 +9992,77 @@ var attentive_default3 = {
6965
9992
  }
6966
9993
  };
6967
9994
 
9995
+ // lib/connections/icons/brightdata.js
9996
+ var brightdata_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
9997
+ <rect width="500" height="500" fill="#0F58FF"/>
9998
+ <circle cx="250" cy="250" r="52" fill="white"/>
9999
+ <rect x="234" y="70" width="32" height="96" rx="16" fill="white"/>
10000
+ <rect x="234" y="334" width="32" height="96" rx="16" fill="white"/>
10001
+ <rect x="70" y="234" width="96" height="32" rx="16" fill="white"/>
10002
+ <rect x="334" y="234" width="96" height="32" rx="16" fill="white"/>
10003
+ <rect x="117" y="140" width="32" height="96" rx="16" transform="rotate(-45 117 140)" fill="white"/>
10004
+ <rect x="304" y="327" width="32" height="96" rx="16" transform="rotate(-45 304 327)" fill="white"/>
10005
+ <rect x="360" y="117" width="32" height="96" rx="16" transform="rotate(45 360 117)" fill="white"/>
10006
+ <rect x="173" y="304" width="32" height="96" rx="16" transform="rotate(45 173 304)" fill="white"/>
10007
+ </svg>`;
10008
+
10009
+ // lib/connections/vendors/brightdata.js
10010
+ var brightdata_default2 = {
10011
+ fields: [
10012
+ {
10013
+ credential: "BRIGHTDATA_BROWSER_URI",
10014
+ input: "password",
10015
+ key: "browserUri",
10016
+ // A wss:// endpoint with the account's user and password in its
10017
+ // authority, which is why it is a password field rather than a url one:
10018
+ // the whole string is the credential.
10019
+ label: "Scraping Browser endpoint",
10020
+ message: "The wss:// Scraping Browser endpoint, carrying its own credentials. Used to render pages that need a real browser.",
10021
+ redact: true,
10022
+ required: true
10023
+ },
10024
+ {
10025
+ credential: "BRIGHTDATA_UNLOCKER_API_KEY",
10026
+ input: "password",
10027
+ key: "unlockerApiKey",
10028
+ label: "Web Unlocker API key",
10029
+ // NO EXPIRY FIELD, and that is a decision rather than an omission. Bright
10030
+ // Data offers an `Unlimited` expiry and recommends against it; Drawbridge
10031
+ // takes the unlimited key deliberately, because there is no way to rotate
10032
+ // this programmatically and a date nothing can act on is a date that only
10033
+ // surfaces after it has already broken scraping. If a fixed expiry is ever
10034
+ // taken instead, the field to add is a stored date and a warning ahead of
10035
+ // it — not a rotation, which the vendor cannot support.
10036
+ message: "Bright Data account settings \u2192 API keys. Taken with an unlimited expiry: their API offers no way to rotate a key, so a dated one can only ever break scraping silently.",
10037
+ redact: true,
10038
+ required: true
10039
+ },
10040
+ {
10041
+ credential: "BRIGHTDATA_UNLOCKER_ZONE",
10042
+ input: "text",
10043
+ key: "unlockerZone",
10044
+ label: "Web Unlocker zone",
10045
+ // Not secret — a zone is a label on the account, and it is shown rather
10046
+ // than redacted so an operator can confirm which one is in use without
10047
+ // clearing the field to read it.
10048
+ message: "The zone name the Unlocker requests run against.",
10049
+ required: true
10050
+ }
10051
+ ],
10052
+ icon: brightdata_default,
10053
+ name: "Bright Data",
10054
+ slug: "brightdata",
10055
+ // WHERE THE FACTS CAME FROM, read 2026-09-16.
10056
+ urls: {
10057
+ api: "https://docs.brightdata.com/api-reference/authentication",
10058
+ dashboard: "https://brightdata.com/cp/setting/users",
10059
+ // Bright Data has no scope model — a key carries the account's own access —
10060
+ // so there is nothing to link. `false` is the recorded answer rather than a
10061
+ // missing key.
10062
+ scopes: false
10063
+ }
10064
+ };
10065
+
6968
10066
  // lib/connections/vendors/hubspot.js
6969
10067
  var hubspot_default2 = {
6970
10068
  // WHAT AN ADMIN TYPES on the provider screen. `credential` names the env var a
@@ -7278,7 +10376,7 @@ var twilio_default2 = {
7278
10376
 
7279
10377
  // lib/connections/index.js
7280
10378
  var QUEUES = ["connection", "notification", "segment", "webhook"];
7281
- var implemented = (hooks, path) => {
10379
+ var implemented2 = (hooks, path) => {
7282
10380
  const hook = path.split(".").reduce((node, key) => node == null ? void 0 : node[key], hooks);
7283
10381
  return typeof hook === "function" || !!hook && typeof hook === "object";
7284
10382
  };
@@ -7340,7 +10438,7 @@ var checkIcon = (owner, icon) => {
7340
10438
  }
7341
10439
  };
7342
10440
  var vendors = Object.freeze(Object.fromEntries(
7343
- [attentive_default3, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
10441
+ [attentive_default3, brightdata_default2, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
7344
10442
  ));
7345
10443
  var buildVendor = (vendor) => {
7346
10444
  if (!(vendor == null ? void 0 : vendor.slug)) throw new Error("A vendor needs a slug");
@@ -7367,7 +10465,7 @@ for (const [slug2, vendor] of Object.entries(vendors)) {
7367
10465
  buildVendor(vendor);
7368
10466
  }
7369
10467
  var build = (manifest) => {
7370
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x;
10468
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
7371
10469
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
7372
10470
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
7373
10471
  if (manifest == null ? void 0 : manifest.private) throw new Error(manifest.slug + " declares private \u2014 that is feature : false now");
@@ -7396,7 +10494,7 @@ var build = (manifest) => {
7396
10494
  if (!field.hook.startsWith("resources.")) {
7397
10495
  throw new Error(manifest.slug + "." + field.key + " reads from " + field.hook + " \u2014 a picker may only read resources.*");
7398
10496
  }
7399
- if (!implemented(manifest.hooks, field.hook)) {
10497
+ if (!implemented2(manifest.hooks, field.hook)) {
7400
10498
  throw new Error(manifest.slug + "." + field.key + " reads " + field.hook + ", which this vendor does not implement");
7401
10499
  }
7402
10500
  }
@@ -7435,7 +10533,7 @@ var build = (manifest) => {
7435
10533
  }
7436
10534
  let callback = null;
7437
10535
  const type = kinds[0];
7438
- if (["install", "oauth"].includes(type) && !implemented(manifest.hooks, "lifecycle.health")) {
10536
+ if (["install", "oauth"].includes(type) && !implemented2(manifest.hooks, "lifecycle.health")) {
7439
10537
  throw new Error(
7440
10538
  manifest.slug + " is " + type + " and must implement hooks.lifecycle.health \u2014 a credential somebody else can revoke, expire or narrow tells us nothing when it dies"
7441
10539
  );
@@ -7464,10 +10562,10 @@ var build = (manifest) => {
7464
10562
  }
7465
10563
  callback = "/api/connection/" + manifest.slug + "/callback";
7466
10564
  }
7467
- if (implemented(manifest.hooks, "inbound.event") && typeof ((_n = (_m = manifest.hooks) == null ? void 0 : _m.inbound) == null ? void 0 : _n.event) !== "function" && !((_r = (_q = (_p = (_o = manifest.hooks) == null ? void 0 : _o.inbound) == null ? void 0 : _p.config) == null ? void 0 : _q.headers) == null ? void 0 : _r.event)) {
10565
+ if (implemented2(manifest.hooks, "inbound.event") && typeof ((_n = (_m = manifest.hooks) == null ? void 0 : _m.inbound) == null ? void 0 : _n.event) !== "function" && !((_r = (_q = (_p = (_o = manifest.hooks) == null ? void 0 : _o.inbound) == null ? void 0 : _p.config) == null ? void 0 : _q.headers) == null ? void 0 : _r.event)) {
7468
10566
  throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
7469
10567
  }
7470
- if (implemented(manifest.hooks, "inbound.verify") && !((_v = (_u = (_t = (_s = manifest.hooks) == null ? void 0 : _s.inbound) == null ? void 0 : _t.config) == null ? void 0 : _u.headers) == null ? void 0 : _v.signature)) {
10568
+ if (implemented2(manifest.hooks, "inbound.verify") && !((_v = (_u = (_t = (_s = manifest.hooks) == null ? void 0 : _s.inbound) == null ? void 0 : _t.config) == null ? void 0 : _u.headers) == null ? void 0 : _v.signature)) {
7471
10569
  throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
7472
10570
  }
7473
10571
  if (!Array.isArray((_w = manifest == null ? void 0 : manifest.content) == null ? void 0 : _w.guide) || !manifest.content.guide.length) {
@@ -7478,7 +10576,7 @@ var build = (manifest) => {
7478
10576
  if (Array.isArray(node)) {
7479
10577
  for (const verb of node) {
7480
10578
  const hook = declared2 == null ? void 0 : declared2[verb];
7481
- if (hook !== false && !implemented({ [verb]: hook }, verb)) {
10579
+ if (hook !== false && !implemented2({ [verb]: hook }, verb)) {
7482
10580
  throw new Error(manifest.slug + " must answer hooks." + [...path, verb].join(".") + " \u2014 false, a function, or {} if another repo implements it");
7483
10581
  }
7484
10582
  }
@@ -7624,7 +10722,7 @@ var build = (manifest) => {
7624
10722
  if (!QUEUES.includes(declared2 == null ? void 0 : declared2.queue)) {
7625
10723
  throw new Error(manifest.slug + " step " + type2 + " needs a queue \u2014 one of " + QUEUES.join(", "));
7626
10724
  }
7627
- if (declared2.hook && !implemented(manifest.hooks, declared2.hook)) {
10725
+ if (declared2.hook && !implemented2(manifest.hooks, declared2.hook)) {
7628
10726
  throw new Error(manifest.slug + " step " + type2 + " points at hook " + declared2.hook + ", which this vendor does not implement");
7629
10727
  }
7630
10728
  for (const trigger of declared2.triggers || []) {
@@ -7633,11 +10731,22 @@ var build = (manifest) => {
7633
10731
  manifest.slug + " step " + type2 + " may be triggered by " + trigger + ", which is not a trigger \u2014 one of " + Object.keys(TRIGGERS).join(", ")
7634
10732
  );
7635
10733
  }
7636
- if (declared2.trigger) {
7637
- const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === declared2.trigger.event && TRIGGERS[key].type === declared2.trigger.type)];
10734
+ if (declared2.workflow) {
10735
+ if (!declared2.workflow.key) {
10736
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no key \u2014 the title several steps share");
10737
+ }
10738
+ if (typeof declared2.workflow.order !== "number") {
10739
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no order \u2014 two steps in one row need a sequence");
10740
+ }
10741
+ if (declared2.trigger) {
10742
+ throw new Error(manifest.slug + " step " + type2 + " declares BOTH a trigger and a workflow \u2014 the workflow carries the trigger for every step in it");
10743
+ }
10744
+ }
10745
+ for (const candidate of [declared2.trigger, (_y = declared2.workflow) == null ? void 0 : _y.trigger].filter(Boolean)) {
10746
+ const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === candidate.event && TRIGGERS[key].type === candidate.type)];
7638
10747
  if (!known2) {
7639
10748
  throw new Error(
7640
- manifest.slug + " step " + type2 + " declares trigger { event : " + declared2.trigger.event + ", type : " + declared2.trigger.type + " }, which nothing dispatches"
10749
+ manifest.slug + " step " + type2 + " declares trigger { event : " + candidate.event + ", type : " + candidate.type + " }, which nothing dispatches"
7641
10750
  );
7642
10751
  }
7643
10752
  }
@@ -7707,13 +10816,14 @@ var publicSettingsBySlug = Object.fromEntries(
7707
10816
  var connectionSteps = (env = {}) => Object.entries(availableConnections(env)).flatMap(
7708
10817
  ([slug2, manifest]) => leaves(manifest.steps).map(([, step]) => ({ ...step({}), slug: slug2 }))
7709
10818
  );
10819
+ var implementsHook = (hooks, name) => implemented2(hooks, name);
7710
10820
  var hookSupport = (name) => ({
7711
- no: Object.keys(connections).filter((slug2) => !implemented(connections[slug2].hooks, name)),
7712
- yes: Object.keys(connections).filter((slug2) => implemented(connections[slug2].hooks, name))
10821
+ no: Object.keys(connections).filter((slug2) => !implemented2(connections[slug2].hooks, name)),
10822
+ yes: Object.keys(connections).filter((slug2) => implemented2(connections[slug2].hooks, name))
7713
10823
  });
7714
10824
  var connectFields = (slug2) => {
7715
10825
  var _a;
7716
- return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
10826
+ return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required: required2, search }) => ({
7717
10827
  ...copy && { copy: true },
7718
10828
  ...hook && { hook },
7719
10829
  ...from && { from },
@@ -7726,7 +10836,7 @@ var connectFields = (slug2) => {
7726
10836
  // Declared false only where the vendor cannot filter, so a picker does not
7727
10837
  // offer a search box that quietly searches one page.
7728
10838
  ...search === false && { search: false },
7729
- required: Boolean(required),
10839
+ required: Boolean(required2),
7730
10840
  // A UI hint, not a leak: the form uses it to stop requiring the field once
7731
10841
  // the connection exists, and to say "leave blank to keep" — because the GET
7732
10842
  // response deliberately never returns the stored value.
@@ -7865,6 +10975,7 @@ export {
7865
10975
  HOOK_PROPS,
7866
10976
  HOOK_SLOT_PROPS,
7867
10977
  INPUTS,
10978
+ MARKERS,
7868
10979
  OAUTH_ENDPOINTS,
7869
10980
  OAUTH_FIELDS,
7870
10981
  OUTCOMES,
@@ -7885,28 +10996,35 @@ export {
7885
10996
  buildVendor,
7886
10997
  catalogConnections,
7887
10998
  connectFields,
10999
+ connectionSettings,
7888
11000
  connectionSteps,
7889
11001
  connections,
7890
11002
  consentUrl,
7891
11003
  effectsOf,
7892
11004
  ensureSystemWorkflows,
7893
11005
  hookSupport,
11006
+ implementsHook,
7894
11007
  isStale,
7895
11008
  isStatus,
7896
11009
  mergeSettings,
11010
+ perform,
7897
11011
  pkcePair,
7898
11012
  projectConnection,
7899
11013
  publicConnectionKeys,
7900
11014
  publicSettingsBySlug,
11015
+ reconcileConnectionScopes,
7901
11016
  redactSettings,
11017
+ reference,
7902
11018
  resolveConnection,
7903
11019
  runHook,
7904
11020
  scopesMessage,
11021
+ spliced,
7905
11022
  stepLabels,
7906
11023
  stepQueues,
7907
11024
  stepRoutes,
7908
11025
  surviving,
7909
11026
  systemSteps,
11027
+ systemWorkflows,
7910
11028
  tokenSettings,
7911
11029
  vendors
7912
11030
  };