@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
package/dist/pricing.js CHANGED
@@ -1,3 +1,9 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all2) => {
3
+ for (var name in all2)
4
+ __defProp(target, name, { get: all2[name], enumerable: true });
5
+ };
6
+
1
7
  // lib/channels.js
2
8
  var channels = {
3
9
  email: {
@@ -417,6 +423,10 @@ var HOOKS = Object.freeze({
417
423
  "code",
418
424
  // Create the buyer at the vendor, so an order can be attributed to them.
419
425
  "customer",
426
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
427
+ // campaign's product list is rendered rather than stored, because stock is
428
+ // the one product fact that is stale the instant it is written down.
429
+ "inventory",
420
430
  // An order arrived at the vendor: attribute it, record it, update totals.
421
431
  "order",
422
432
  // Pull product data across on a vendor update.
@@ -484,7 +494,19 @@ var HOOKS = Object.freeze({
484
494
  organizations: Object.freeze(["add", "list", "remove"]),
485
495
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
486
496
  // declares the plan they are being charged on.
487
- plan: Object.freeze(["cancel", "reselect"]),
497
+ //
498
+ // `verify` is the odd one of the three: not something a merchant does, but
499
+ // the question "is this store's billing actually metered right now". It is
500
+ // here because it is the same subject — and because the answer was being
501
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
502
+ // approval webhook, whose copy said in its own comment that it matched the
503
+ // other and did not.
504
+ // `charge` and `verify` are the two that are not merchant actions: one
505
+ // sends a usage charge to the vendor's meter, the other asks whether that
506
+ // meter is actually working. Both are here because both are the same
507
+ // subject — what the merchant pays the vendor — and both were being done
508
+ // from drawbridge-sync with a direct import of the vendor's client.
509
+ plan: Object.freeze(["cancel", "charge", "reselect", "verify"]),
488
510
  // The embedded app's own session, stored by us on the vendor's behalf.
489
511
  session: Object.freeze(["clear", "read", "save"]),
490
512
  // The one-time code that hands a merchant from the embedded app into
@@ -569,7 +591,7 @@ var ANSWER_KEYS = Object.freeze([
569
591
  "usage"
570
592
  ].sort());
571
593
  var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
572
- var INSTALL_SIGNATURE = Object.freeze({ options: ["read", "request"], props: ["context"] });
594
+ var INSTALL_SIGNATURE = Object.freeze({ options: ["read"], props: ["context"] });
573
595
  var LIFECYCLE_SIGNATURE = Object.freeze({ options: ["dispatch"], props: ["context"] });
574
596
  var RESOURCE_SIGNATURE = Object.freeze({
575
597
  options: ["fetcher", "shopify"],
@@ -586,15 +608,30 @@ var HOOK_SLOT_PROPS = Object.freeze({
586
608
  // the second bag would be silently discarded, which is exactly the
587
609
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
588
610
  "auth.token": false,
589
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
590
- // api/route/subdomain.js writes nothing, which is why it never became a hook
591
- // so its signature mirrors `code`, its nearest sibling, and moving the
592
- // route here is a deliberate widening of this line rather than a silent one.
593
- "commerce.cart": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
611
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
612
+ // not for an organization inside a workflow, so it takes no `connection` and
613
+ // no `step`. It reads the storefront with the store's own storefront token
614
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
615
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
616
+ // already carries them, because a credential is a fact of the run rather
617
+ // than a service.
618
+ "commerce.cart": Object.freeze({
619
+ options: ["fetcher", "shopify"],
620
+ props: ["clientId", "clientSecret", "context", "settings"]
621
+ }),
594
622
  "commerce.code": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
595
623
  "commerce.customer": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["connection", "context"] }),
624
+ "commerce.inventory": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context"] }),
596
625
  "commerce.order": Object.freeze({ options: ["conversionRate", "logger", "mintId", "read"], props: ["connection", "context"] }),
597
- "commerce.product": Object.freeze({ options: ["mintId", "read"], props: ["connection", "context", "workflow"] }),
626
+ // THREE OPERATIONS, one slot — record, sync, variants. `settings` and
627
+ // `adminToken` arrived with the pull half: the storefront read needs the
628
+ // store's storefront token and the product-feedback send needs its admin one,
629
+ // and both are resolved by the caller because minting either needs a
630
+ // controller.
631
+ "commerce.product": Object.freeze({
632
+ options: ["adminToken", "mintId", "read", "shopify"],
633
+ props: ["connection", "context", "settings", "workflow"]
634
+ }),
598
635
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
599
636
  // person, not the lead document. `remove` called it `email` and `sync` called
600
637
  // it `doc` on one vendor and `lead` on three — three names for two facts.
@@ -607,19 +644,58 @@ var HOOK_SLOT_PROPS = Object.freeze({
607
644
  "inbound.process": Object.freeze({ options: ["dispatch"], props: ["context"] }),
608
645
  "inbound.receive": Object.freeze({ options: [], props: ["channel", "event", "headers", "payload"] }),
609
646
  "inbound.verify": Object.freeze({ options: [], props: ["body", "channel", "headers", "secret", "url"] }),
610
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
611
- // routes in api/route/shopify-embedded.js become, and the signature is
612
- // deliberately narrow: `context` is the request's own body, `read` and
613
- // `request` are the only services an install surface has been shown to need.
614
- // A route that cannot be expressed inside this line is a finding to bring
615
- // back here, not a name to add at the call site.
647
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
648
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
649
+ // of them; the options are where they differ, and every difference below was
650
+ // forced by a line in those routes rather than chosen.
651
+ //
652
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
653
+ // token slots that write, the two clears, and both account slots answer from
654
+ // the shared install record alone.
655
+ //
656
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
657
+ // Drawbridge owns that the manifest must not:
658
+ //
659
+ // conversionRate, planTitle what Drawbridge charges this organization.
660
+ // The org list renders both, and importing the
661
+ // plan catalogue here is the cycle
662
+ // features -> connections -> shopify -> plans
663
+ // -> features that was just broken.
664
+ // currencies which settlement currencies Drawbridge can
665
+ // bill. A store outside them fails the
666
+ // connection validator INSIDE the transaction,
667
+ // which surfaces as an opaque 500, so `add`
668
+ // checks first and says so.
669
+ // mintId the connection's id, known before the write
670
+ // that creates it, because the organization's
671
+ // own refs have to name it in the same
672
+ // transaction.
673
+ // shopify the vendor. Four slots call the store: two
674
+ // read the live plan, two release it.
675
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
676
+ // `reselect` is the only slot with one. See its
677
+ // implementation for why the order is the point.
616
678
  "install.account.link": INSTALL_SIGNATURE,
617
679
  "install.account.unlink": INSTALL_SIGNATURE,
618
- "install.organizations.add": INSTALL_SIGNATURE,
619
- "install.organizations.list": INSTALL_SIGNATURE,
680
+ "install.organizations.add": Object.freeze({
681
+ options: ["currencies", "mintId", "read", "shopify"],
682
+ props: ["context"]
683
+ }),
684
+ "install.organizations.list": Object.freeze({
685
+ options: ["conversionRate", "planTitle", "read", "shopify"],
686
+ props: ["context"]
687
+ }),
620
688
  "install.organizations.remove": INSTALL_SIGNATURE,
621
- "install.plan.cancel": INSTALL_SIGNATURE,
622
- "install.plan.reselect": INSTALL_SIGNATURE,
689
+ "install.plan.cancel": Object.freeze({ options: ["read", "shopify"], props: ["context"] }),
690
+ // `manifest` because the handle this charge bills under is the vendor's own
691
+ // declaration (auth.install.billing.handle), and checking the job against the
692
+ // declaration is what the enqueuer-checks-itself version could not do.
693
+ "install.plan.charge": Object.freeze({
694
+ options: ["shopify"],
695
+ props: ["clientId", "clientSecret", "context", "manifest"]
696
+ }),
697
+ "install.plan.reselect": Object.freeze({ options: ["mark", "read", "shopify"], props: ["context"] }),
698
+ "install.plan.verify": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["context"] }),
623
699
  "install.session.clear": INSTALL_SIGNATURE,
624
700
  "install.session.read": INSTALL_SIGNATURE,
625
701
  "install.session.save": INSTALL_SIGNATURE,
@@ -670,6 +746,16 @@ var TRIGGERS = Object.freeze({
670
746
  "range.ended": Object.freeze({ event: "range.ended", type: "range" }),
671
747
  "range.started": Object.freeze({ event: "range.started", type: "range" }),
672
748
  "schedule.day": Object.freeze({ event: "day", type: "schedule" }),
749
+ // THE THREE SHOPIFY TRIGGERS, which were never in this vocabulary because the
750
+ // four workflows they fire were hand-written in drawbridge-sync's register job
751
+ // rather than provisioned from the manifest. `webhook` is a fourth trigger
752
+ // TYPE, and it is the honest name: these fire when the vendor calls us, which
753
+ // is neither an event Drawbridge raised nor a schedule it keeps.
754
+ "shopify.order.create": Object.freeze({ event: "shopify.order.create", type: "webhook" }),
755
+ "shopify.product.update": Object.freeze({ event: "shopify.product.update", type: "webhook" }),
756
+ // The token audit, dispatched programmatically by the register job when a
757
+ // store's grant is exchanged or rotated.
758
+ "shopify.token": Object.freeze({ event: "shopify.token", type: "event" }),
673
759
  "schedule.month": Object.freeze({ event: "month", type: "schedule" }),
674
760
  "schedule.week": Object.freeze({ event: "week", type: "schedule" }),
675
761
  "segment.contact.add": Object.freeze({ event: "segment.contact.add", type: "event" }),
@@ -722,7 +808,7 @@ var OUTCOMES = Object.freeze({
722
808
  unimplemented: "unimplemented",
723
809
  unsupported: "unsupported"
724
810
  });
725
- var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
811
+ var STATUSES = Object.freeze(["active", "error", "pending"]);
726
812
  var PLATFORM_CREDENTIALS = Object.freeze([
727
813
  // Seals a merchant's stored connection settings. The webhook connection is
728
814
  // gated on it because its whole credential is a secret WE mint and store, so
@@ -778,6 +864,52 @@ var ERROR_SOURCES = Object.freeze({
778
864
  "shopify.register.webhooks": true
779
865
  });
780
866
 
867
+ // lib/encrypt.js
868
+ import crypto2 from "crypto";
869
+
870
+ // lib/token.js
871
+ import crypto from "crypto";
872
+ var generate = (bytes = 32, encoding = "base64url") => {
873
+ const buf = crypto.randomBytes(bytes);
874
+ return encoding ? buf.toString(encoding) : buf;
875
+ };
876
+ var hash = (value, key) => {
877
+ if (!value) return null;
878
+ if (key) {
879
+ return crypto.createHmac("sha256", key).update(String(value)).digest("base64url");
880
+ }
881
+ return crypto.createHash("sha256").update(String(value)).digest("base64url");
882
+ };
883
+
884
+ // lib/encrypt.js
885
+ var ALGORITHM = "aes-256-gcm";
886
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
887
+ var encrypt = (value) => {
888
+ const iv = generate(12, null);
889
+ const cipher = crypto2.createCipheriv(ALGORITHM, getKey(), iv);
890
+ const data2 = Buffer.concat([
891
+ cipher.update(JSON.stringify(value), "utf8"),
892
+ cipher.final()
893
+ ]);
894
+ const tag = cipher.getAuthTag();
895
+ return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
896
+ };
897
+ var decrypt = (value) => {
898
+ if (typeof value !== "string") return value;
899
+ const [ivHex, tagHex, dataHex] = value.split(":");
900
+ const decipher = crypto2.createDecipheriv(
901
+ ALGORITHM,
902
+ getKey(),
903
+ Buffer.from(ivHex, "hex")
904
+ );
905
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
906
+ const result = Buffer.concat([
907
+ decipher.update(Buffer.from(dataHex, "hex")),
908
+ decipher.final()
909
+ ]);
910
+ return JSON.parse(result.toString("utf8"));
911
+ };
912
+
781
913
  // lib/connections/oauth.js
782
914
  import { createHash, randomBytes } from "crypto";
783
915
  var credentials = ({ basic, clientId, clientSecret }) => basic ? {
@@ -804,10 +936,10 @@ var authToken = async ({
804
936
  const renewing = !code2;
805
937
  if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
806
938
  if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
807
- const client = credentials({ basic, clientId, clientSecret });
939
+ const client2 = credentials({ basic, clientId, clientSecret });
808
940
  const response = await fetcher(descriptor.endpoints.token, {
809
941
  body: new URLSearchParams({
810
- ...client.body,
942
+ ...client2.body,
811
943
  ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
812
944
  code: decodeURIComponent(String(code2 || "").trim()),
813
945
  grant_type: "authorization_code",
@@ -815,7 +947,7 @@ var authToken = async ({
815
947
  ...descriptor.pkce && { code_verifier: verifier }
816
948
  }
817
949
  }),
818
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
950
+ headers: { "content-type": "application/x-www-form-urlencoded", ...client2.headers },
819
951
  method: "POST",
820
952
  signal: AbortSignal.timeout(15e3)
821
953
  });
@@ -1210,9 +1342,18 @@ var scaffold_default = {
1210
1342
  commerce: {
1211
1343
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
1212
1344
  // the last api surface Shopify owns outside a hook.
1213
- cart: async ({ connection: connection2, context }, { read } = {}) => ANSWER,
1345
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
1346
+ // page, not for an organization inside a workflow. It reads the storefront
1347
+ // with the store's storefront token and mints a checkout token from
1348
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
1349
+ // as props — the way auth.disconnect already carries it.
1350
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify } = {}) => ANSWER,
1214
1351
  code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => ANSWER,
1215
1352
  customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => ANSWER,
1353
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
1354
+ // fact that is stale the instant it is written down, so the campaign
1355
+ // product list asks the vendor and caches the answer for a minute.
1356
+ inventory: async ({ connection: connection2, context }, { adminToken, shopify } = {}) => ANSWER,
1216
1357
  order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => ANSWER,
1217
1358
  // THIS IS WHERE `product.insert` LANDS.
1218
1359
  //
@@ -1234,7 +1375,7 @@ var scaffold_default = {
1234
1375
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
1235
1376
  // a log. Phase 1 closes it and build() checks every trigger against it.
1236
1377
  product: {
1237
- hook: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => ANSWER,
1378
+ hook: async ({ connection: connection2, context, settings, workflow }, { adminToken, mintId, read, shopify } = {}) => ANSWER,
1238
1379
  step: () => ({
1239
1380
  description: "Syncs product data from the vendor when a product changes.",
1240
1381
  key: "Vendor Product Sync",
@@ -1382,8 +1523,19 @@ var scaffold_default = {
1382
1523
  // Vendor billing, the only group that is not generic. Pairs with
1383
1524
  // auth.install.billing, which declares what the merchant is being charged.
1384
1525
  plan: {
1385
- cancel: async ({ context }, { dispatch, read } = {}) => ANSWER,
1386
- reselect: async ({ context }, { dispatch, read } = {}) => ANSWER
1526
+ cancel: async ({ context }, { read, shopify } = {}) => ANSWER,
1527
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
1528
+ // sent to the vendor's meter. The credential pair rides in as props
1529
+ // because the charge authenticates as the APP, not as the store.
1530
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify } = {}) => ANSWER,
1531
+ // `mark` is the one write a description cannot carry: the marker must
1532
+ // be persisted BEFORE the approval is released, and a described write
1533
+ // lands after the hook returns.
1534
+ reselect: async ({ context }, { mark, read, shopify } = {}) => ANSWER,
1535
+ // NOT A MERCHANT ACTION — the question "is this store's billing
1536
+ // actually metered right now", asked by the daily health check and by
1537
+ // the vendor's own approval webhook, which used to hand-copy it.
1538
+ verify: async ({ context }, { adminToken, read, shopify } = {}) => ANSWER
1387
1539
  },
1388
1540
  // The embedded app's own session, stored by us on the vendor's behalf.
1389
1541
  session: {
@@ -1869,16 +2021,19 @@ var attentive_default2 = {
1869
2021
  "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.",
1870
2022
  "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."
1871
2023
  ],
1872
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
1873
- // The dashboard already carries generic reasons for every way an
1874
- // authorization can fail — declined, expired state, a vendor we are not set
1875
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
1876
- // failures need saying differently. This one's do not.
2024
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
2025
+ // consent screen has two ways to end: they said no, or something between
2026
+ // here and the vendor went wrong.
1877
2027
  //
1878
- // Answered rather than omitted, because `errors` existed on two of six
1879
- // manifests and there was no way to tell "nothing to add" from "nobody
1880
- // wrote it".
1881
- errors: false,
2028
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
2029
+ // authorization"; a merchant connecting two accounts in one sitting needs
2030
+ // to know which one this was.
2031
+ errors: {
2032
+ connect: {
2033
+ denied: "The Attentive authorization was declined, so nothing was connected.",
2034
+ invalid: "We couldn't complete the Attentive connection. Try connecting again."
2035
+ }
2036
+ },
1882
2037
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
1883
2038
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
1884
2039
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -2406,6 +2561,15 @@ var readEventHeader = ({ descriptor, headers }) => ({ result: { event: headers[d
2406
2561
  var DEFAULT_TIMEOUT_MS = 15e3;
2407
2562
  var request = async ({
2408
2563
  body,
2564
+ // THE TRANSPORT, injectable and defaulted to the real one.
2565
+ //
2566
+ // Every other vendor client in this package takes a `fetcher` — it is how
2567
+ // klaviyo, mailchimp and attentive are tested without a network, and it is a
2568
+ // declared HOOK_OPTION. The Shopify client had no seam at all, so the only way
2569
+ // to test a hook that used it was to inject the whole SDK namespace; that
2570
+ // injection existed to work around an import cycle, and when the cycle went
2571
+ // the tests lost their only hold. This is the seam they should have had.
2572
+ fetcher = fetch,
2409
2573
  headers = {},
2410
2574
  method = "GET",
2411
2575
  query,
@@ -2419,7 +2583,7 @@ var request = async ({
2419
2583
  }
2420
2584
  ;
2421
2585
  const isForm = type === "form";
2422
- const response = await fetch(fullUrl.toString(), {
2586
+ const response = await fetcher(fullUrl.toString(), {
2423
2587
  method,
2424
2588
  headers: {
2425
2589
  "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
@@ -2861,15 +3025,12 @@ var drawbridge_default2 = {
2861
3025
  description: [
2862
3026
  "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2863
3027
  ],
2864
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2865
- // The dashboard already carries generic reasons for every way an
2866
- // authorization can fail declined, expired state, a vendor we are not set
2867
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2868
- // failures need saying differently. This one's do not.
3028
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
3029
+ // own manifest always on, private, never in the catalogue — and it exists
3030
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
3031
+ // describe a consent screen this connection does not have.
2869
3032
  //
2870
- // Answered rather than omitted, because `errors` existed on two of six
2871
- // manifests and there was no way to tell "nothing to add" from "nobody
2872
- // wrote it".
3033
+ // `false` is the recorded decision, not silence.
2873
3034
  errors: false,
2874
3035
  excerpt: "The steps Drawbridge runs itself.",
2875
3036
  guide: [
@@ -3629,10 +3790,10 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3629
3790
 
3630
3791
  // lib/connections/scopes.js
3631
3792
  var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3632
- var missingScopes = ({ granted, required }) => {
3793
+ var missingScopes = ({ granted, required: required2 }) => {
3633
3794
  if (granted === null || granted === void 0 || granted === "") return null;
3634
3795
  const held = new Set(list(granted));
3635
- return list(required).filter((scope) => !held.has(scope));
3796
+ return list(required2).filter((scope) => !held.has(scope));
3636
3797
  };
3637
3798
 
3638
3799
  // lib/connections/manifests/klaviyo.js
@@ -4531,16 +4692,19 @@ var mailchimp_default2 = {
4531
4692
  "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.",
4532
4693
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
4533
4694
  ],
4534
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4535
- // The dashboard already carries generic reasons for every way an
4536
- // authorization can fail — declined, expired state, a vendor we are not set
4537
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4538
- // failures need saying differently. This one's do not.
4695
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
4696
+ // consent screen has two ways to end: they said no, or something between
4697
+ // here and the vendor went wrong.
4539
4698
  //
4540
- // Answered rather than omitted, because `errors` existed on two of six
4541
- // manifests and there was no way to tell "nothing to add" from "nobody
4542
- // wrote it".
4543
- errors: false,
4699
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
4700
+ // authorization"; a merchant connecting two accounts in one sitting needs
4701
+ // to know which one this was.
4702
+ errors: {
4703
+ connect: {
4704
+ denied: "The Mailchimp authorization was declined, so nothing was connected.",
4705
+ invalid: "We couldn't complete the Mailchimp connection. Try connecting again."
4706
+ }
4707
+ },
4544
4708
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
4545
4709
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
4546
4710
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -4639,7 +4803,7 @@ var mailchimp_default2 = {
4639
4803
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4640
4804
  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);
4641
4805
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4642
- const hash = subscriberHash(email);
4806
+ const hash2 = subscriberHash(email);
4643
4807
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4644
4808
  const lastName = restOfName.join(" ");
4645
4809
  const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
@@ -4648,7 +4812,7 @@ var mailchimp_default2 = {
4648
4812
  ...lastName && { LNAME: lastName },
4649
4813
  ...phone && { PHONE: phone }
4650
4814
  };
4651
- const member = await api3("/lists/" + audience + "/members/" + hash, {
4815
+ const member = await api3("/lists/" + audience + "/members/" + hash2, {
4652
4816
  dc: settings == null ? void 0 : settings.dc,
4653
4817
  fetcher,
4654
4818
  method: "PUT",
@@ -4689,7 +4853,7 @@ var mailchimp_default2 = {
4689
4853
  status: joined.has(entry.id) ? "active" : "inactive"
4690
4854
  }));
4691
4855
  if (tags.length > 0) {
4692
- await api3("/lists/" + audience + "/members/" + hash + "/tags", {
4856
+ await api3("/lists/" + audience + "/members/" + hash2 + "/tags", {
4693
4857
  dc: settings == null ? void 0 : settings.dc,
4694
4858
  fetcher,
4695
4859
  method: "POST",
@@ -4700,12 +4864,12 @@ var mailchimp_default2 = {
4700
4864
  }
4701
4865
  return {
4702
4866
  // Merged into `context` for later steps in this run.
4703
- context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
4867
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 },
4704
4868
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4705
4869
  request: request2,
4706
4870
  // Recorded on the run for support to read back, not a write
4707
4871
  // instruction — the hook has already written what it needed to.
4708
- response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
4872
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 }
4709
4873
  };
4710
4874
  }
4711
4875
  },
@@ -5075,164 +5239,2055 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
5075
5239
  <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"/>
5076
5240
  </svg>`;
5077
5241
 
5078
- // lib/email.js
5079
- var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
5080
- var toCanonicalEmail = (value) => {
5081
- if (!value || typeof value !== "string") return null;
5082
- const email = value.trim().toLowerCase();
5083
- const at = email.lastIndexOf("@");
5084
- if (at < 1 || at === email.length - 1) return null;
5085
- let local = email.slice(0, at);
5086
- const domain = email.slice(at + 1);
5087
- const plus = local.indexOf("+");
5088
- if (plus > 0) local = local.slice(0, plus);
5089
- if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
5090
- if (!local) return null;
5091
- return local + "@" + domain;
5092
- };
5242
+ // lib/shopify/admin.js
5243
+ var admin_exports = {};
5244
+ __export(admin_exports, {
5245
+ adminFetch: () => adminFetch,
5246
+ createDiscountCode: () => createDiscountCode,
5247
+ createUsageRecord: () => createUsageRecord,
5248
+ createUsageSubscription: () => createUsageSubscription,
5249
+ getActiveAppSubscriptions: () => getActiveAppSubscriptions,
5250
+ getDiscounts: () => getDiscounts,
5251
+ getOrCreateCustomer: () => getOrCreateCustomer,
5252
+ getProductInventory: () => getProductInventory,
5253
+ sendProductResourceFeedback: () => sendProductResourceFeedback
5254
+ });
5093
5255
 
5094
- // lib/encrypt.js
5095
- import crypto from "crypto";
5096
- var ALGORITHM = "aes-256-gcm";
5097
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
5098
- var decrypt = (value) => {
5099
- if (typeof value !== "string") return value;
5100
- const [ivHex, tagHex, dataHex] = value.split(":");
5101
- const decipher = crypto.createDecipheriv(
5102
- ALGORITHM,
5103
- getKey(),
5104
- Buffer.from(ivHex, "hex")
5105
- );
5106
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
5107
- const result = Buffer.concat([
5108
- decipher.update(Buffer.from(dataHex, "hex")),
5109
- decipher.final()
5110
- ]);
5111
- return JSON.parse(result.toString("utf8"));
5112
- };
5256
+ // lib/shopify/constants.js
5257
+ var SHOPIFY_STOREFRONT_API_VERSION = "2025-01";
5258
+ var SHOPIFY_ADMIN_API_VERSION = "2026-04";
5259
+ var SHOPIFY_APP_API_VERSION = "unstable";
5260
+ var SHOPIFY_REQUIRED_SCOPES = [
5261
+ "read_customers",
5262
+ "write_customers",
5263
+ "read_discounts",
5264
+ "write_discounts",
5265
+ "read_orders",
5266
+ "read_products",
5267
+ "read_publications",
5268
+ "write_resource_feedbacks",
5269
+ "unauthenticated_read_checkouts",
5270
+ "unauthenticated_read_product_inventory",
5271
+ "unauthenticated_read_product_listings",
5272
+ "unauthenticated_write_checkouts"
5273
+ ];
5274
+ var REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1e3;
5275
+ var ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
5276
+ var PARTNER_API_VERSION = process.env.SHOPIFY_PARTNER_API_VERSION || "2026-07";
5113
5277
 
5114
- // lib/slugify.js
5115
- import slug from "slugify";
5116
- var slugify = (value, nochars = false) => {
5117
- const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
5118
- const replacement = nochars ? "" : "-";
5119
- return slug(
5120
- value,
5121
- {
5122
- remove,
5123
- replacement,
5124
- lower: true,
5125
- trim: true
5278
+ // lib/shopify/admin.js
5279
+ var adminUrl = (domain) => `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}`;
5280
+ var adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
5281
+ var _a;
5282
+ const { data: data2, errors } = await request({
5283
+ fetcher,
5284
+ method: "POST",
5285
+ url: adminUrl(domain) + "/graphql.json",
5286
+ headers: {
5287
+ "X-Shopify-Access-Token": adminAccessToken
5288
+ },
5289
+ body: {
5290
+ query,
5291
+ ...variables && { variables }
5126
5292
  }
5127
- );
5293
+ });
5294
+ if (errors) {
5295
+ throw new Error("Shopify GraphQL error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
5296
+ }
5297
+ ;
5298
+ return data2;
5128
5299
  };
5129
-
5130
- // lib/connections/manifests/shopify.js
5131
- var toLine = ({
5132
- price,
5133
- product_id: productId,
5134
- quantity,
5135
- title,
5136
- variant_id: variantId,
5137
- variant_title: variantTitle
5138
- }) => ({
5139
- price: parseFloat(price) || 0,
5140
- productId: productId ? "gid://shopify/Product/" + productId : null,
5141
- quantity: quantity || 1,
5142
- title: title || null,
5143
- variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
5144
- variantTitle: variantTitle || null
5145
- });
5146
- var attributeLineItems = (lineItems = []) => lineItems.reduce(
5147
- (acc, item) => {
5148
- const attrs = (item.properties || []).reduce(
5149
- (map, { name, value }) => {
5150
- map[name] = value;
5151
- return map;
5152
- },
5153
- {}
5154
- );
5155
- if (!attrs["_drwbrdg_ca"]) return acc;
5156
- if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
5157
- const line = toLine(item);
5158
- acc.attributedGross += line.price * line.quantity;
5159
- acc.attributedLines.push(line);
5160
- return acc;
5161
- },
5162
- { attrMap: {}, attributedGross: 0, attributedLines: [] }
5163
- );
5164
- var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
5165
- var blockedReason = (discount) => {
5166
- var _a;
5167
- if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
5168
- const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
5169
- if (buyers && buyers !== "DiscountBuyerSelectionAll") {
5170
- 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.";
5300
+ var DISCOUNT_FIELDS = `
5301
+ title
5302
+ status
5303
+ startsAt
5304
+ endsAt
5305
+ usageLimit
5306
+ asyncUsageCount
5307
+ appliesOncePerCustomer
5308
+ context { __typename }
5309
+ `;
5310
+ var discountsQuery = (search, cursor, limit) => `{
5311
+ codeDiscountNodes(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}) {
5312
+ edges {
5313
+ node {
5314
+ id
5315
+ codeDiscount {
5316
+ ... on DiscountCodeBasic { ${DISCOUNT_FIELDS} }
5317
+ ... on DiscountCodeBxgy { ${DISCOUNT_FIELDS} }
5318
+ ... on DiscountCodeFreeShipping { ${DISCOUNT_FIELDS} }
5319
+ ... on DiscountCodeApp { ${DISCOUNT_FIELDS} }
5320
+ }
5321
+ }
5322
+ }
5323
+ pageInfo {
5324
+ endCursor
5325
+ hasNextPage
5326
+ hasPreviousPage
5327
+ startCursor
5328
+ }
5329
+ }
5330
+ }`;
5331
+ var getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
5332
+ const data2 = await adminFetch({
5333
+ fetcher,
5334
+ adminAccessToken,
5335
+ domain,
5336
+ query: discountsQuery(search, cursor, limit)
5337
+ });
5338
+ return data2 == null ? void 0 : data2.codeDiscountNodes;
5339
+ };
5340
+ var discountGid = (id) => String(id).startsWith("gid://") ? String(id) : "gid://shopify/DiscountCodeNode/" + id;
5341
+ var createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code: code2 }) => {
5342
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5343
+ const added = await adminFetch({
5344
+ fetcher,
5345
+ adminAccessToken,
5346
+ domain,
5347
+ query: `
5348
+ mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
5349
+ discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
5350
+ bulkCreation {
5351
+ id
5352
+ }
5353
+ userErrors {
5354
+ field
5355
+ message
5356
+ }
5357
+ }
5358
+ }
5359
+ `,
5360
+ variables: {
5361
+ codes: [{ code: code2 }],
5362
+ discountId: discountGid(discountId)
5363
+ }
5364
+ });
5365
+ const addErrors = (_a = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _a.userErrors;
5366
+ if (addErrors == null ? void 0 : addErrors.length) {
5367
+ throw new Error("Shopify discount code create failed: " + addErrors[0].message);
5171
5368
  }
5172
5369
  ;
5173
- if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
5174
- return "This discount has reached its total usage limit.";
5370
+ const bulkCreationId = (_c = (_b = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _b.bulkCreation) == null ? void 0 : _c.id;
5371
+ if (!bulkCreationId) {
5372
+ throw new Error("Shopify discount code create failed: no bulk creation id returned");
5175
5373
  }
5176
5374
  ;
5177
- return null;
5375
+ for (let attempt = 0; attempt < 15; attempt++) {
5376
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5377
+ const polled = await adminFetch({
5378
+ fetcher,
5379
+ adminAccessToken,
5380
+ domain,
5381
+ query: `
5382
+ query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
5383
+ discountRedeemCodeBulkCreation(id: $id) {
5384
+ done
5385
+ codes(first: 1) {
5386
+ nodes {
5387
+ code
5388
+ discountRedeemCode {
5389
+ id
5390
+ }
5391
+ errors {
5392
+ message
5393
+ }
5394
+ }
5395
+ }
5396
+ }
5397
+ }
5398
+ `,
5399
+ variables: {
5400
+ id: bulkCreationId
5401
+ }
5402
+ });
5403
+ const creation = polled == null ? void 0 : polled.discountRedeemCodeBulkCreation;
5404
+ if (!(creation == null ? void 0 : creation.done)) continue;
5405
+ const node = (_e = (_d = creation.codes) == null ? void 0 : _d.nodes) == null ? void 0 : _e[0];
5406
+ if (!((_f = node == null ? void 0 : node.discountRedeemCode) == null ? void 0 : _f.id)) {
5407
+ 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"));
5408
+ }
5409
+ ;
5410
+ return {
5411
+ code: node.code,
5412
+ // Numeric tail keeps the stored shopifyDiscountId in the shape the
5413
+ // legacy REST endpoint returned — it feeds a merchant-visible
5414
+ // workflow template variable, so no gid:// leakage.
5415
+ id: node.discountRedeemCode.id.split("/").pop()
5416
+ };
5417
+ }
5418
+ ;
5419
+ throw new Error("Shopify discount code create timed out waiting for bulk creation " + bulkCreationId);
5178
5420
  };
5179
- var discountWarning = (discount) => {
5180
- if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
5181
- return "This discount hasn't started yet, so codes issued before it does won't work until then.";
5421
+ var getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
5422
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5423
+ const lookupQuery = `
5424
+ query customerByEmail( $query : String! ) {
5425
+ customers( first : 1, query : $query ) {
5426
+ edges {
5427
+ node {
5428
+ id
5429
+ }
5430
+ }
5431
+ }
5432
+ }
5433
+ `;
5434
+ const data2 = await adminFetch({
5435
+ fetcher,
5436
+ adminAccessToken,
5437
+ domain,
5438
+ query: lookupQuery,
5439
+ variables: { query: "email:" + email }
5440
+ });
5441
+ const existing = (_c = (_b = (_a = data2 == null ? void 0 : data2.customers) == null ? void 0 : _a.edges) == null ? void 0 : _b[0]) == null ? void 0 : _c.node;
5442
+ if (existing) return existing;
5443
+ const input = { email };
5444
+ if (firstName) input.firstName = firstName;
5445
+ if (lastName) input.lastName = lastName;
5446
+ if (source) {
5447
+ input.metafields = [{
5448
+ namespace: "drwbrdg",
5449
+ key: "source",
5450
+ type: "single_line_text_field",
5451
+ value: source
5452
+ }];
5182
5453
  }
5183
5454
  ;
5184
- if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
5185
- return null;
5455
+ const result = await adminFetch({
5456
+ fetcher,
5457
+ adminAccessToken,
5458
+ domain,
5459
+ query: `
5460
+ mutation customerCreate( $input : CustomerInput! ) {
5461
+ customerCreate( input : $input ) {
5462
+ customer {
5463
+ id
5464
+ }
5465
+ userErrors {
5466
+ field
5467
+ message
5468
+ }
5469
+ }
5470
+ }
5471
+ `,
5472
+ variables: { input }
5473
+ });
5474
+ const userErrors = (_d = result == null ? void 0 : result.customerCreate) == null ? void 0 : _d.userErrors;
5475
+ if (userErrors == null ? void 0 : userErrors.length) {
5476
+ const emailTaken = userErrors.some((e) => {
5477
+ var _a2;
5478
+ return (_a2 = e.message) == null ? void 0 : _a2.includes("Email has already been taken");
5479
+ });
5480
+ if (emailTaken) {
5481
+ const retry = await adminFetch({
5482
+ fetcher,
5483
+ adminAccessToken,
5484
+ domain,
5485
+ query: lookupQuery,
5486
+ variables: { query: "email:" + email }
5487
+ });
5488
+ 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;
5489
+ if (found) return found;
5490
+ }
5491
+ ;
5492
+ throw new Error("Shopify customer create failed: " + userErrors[0].message);
5493
+ }
5494
+ ;
5495
+ return (_h = result == null ? void 0 : result.customerCreate) == null ? void 0 : _h.customer;
5186
5496
  };
5187
- var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
5188
- var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
5189
- var OAUTH_ERROR_SOURCE = "oauth";
5190
- var BILLING_ERROR_SOURCE = "billing";
5191
- var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
5192
- var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
5193
- var inbound = {
5194
- headers: {
5195
- event: "x-shopify-topic",
5196
- id: "x-shopify-webhook-id",
5197
- shop: "x-shopify-shop-domain",
5198
- signature: "x-shopify-hmac-sha256"
5199
- },
5200
- signature: {
5201
- algorithm: "sha256",
5202
- encoding: "base64",
5203
- secret: "SHOPIFY_API_SECRET"
5497
+ var getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
5498
+ var _a, _b;
5499
+ const data2 = await adminFetch({
5500
+ fetcher,
5501
+ adminAccessToken,
5502
+ domain,
5503
+ query: `{
5504
+ currentAppInstallation {
5505
+ activeSubscriptions {
5506
+ id
5507
+ name
5508
+ status
5509
+ test
5510
+ lineItems {
5511
+ id
5512
+ plan {
5513
+ pricingDetails {
5514
+ __typename
5515
+ }
5516
+ }
5517
+ }
5518
+ }
5519
+ }
5520
+ shop {
5521
+ myshopifyDomain
5522
+ }
5523
+ }`
5524
+ });
5525
+ return {
5526
+ shopDomain: ((_a = data2 == null ? void 0 : data2.shop) == null ? void 0 : _a.myshopifyDomain) || null,
5527
+ subscriptions: (((_b = data2 == null ? void 0 : data2.currentAppInstallation) == null ? void 0 : _b.activeSubscriptions) || []).map((subscription) => {
5528
+ var _a2;
5529
+ return {
5530
+ ...subscription,
5531
+ metered: ((subscription == null ? void 0 : subscription.lineItems) || []).some(
5532
+ (item) => {
5533
+ var _a3, _b2;
5534
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5535
+ }
5536
+ ),
5537
+ // The usage line's own id — the address appUsageRecordCreate charges
5538
+ // against. Null when the approval carries no usage component.
5539
+ usageLineItemId: ((_a2 = ((subscription == null ? void 0 : subscription.lineItems) || []).find(
5540
+ (item) => {
5541
+ var _a3, _b2;
5542
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5543
+ }
5544
+ )) == null ? void 0 : _a2.id) || null
5545
+ };
5546
+ })
5547
+ };
5548
+ };
5549
+ var createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = "USD", domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
5550
+ if (!name || !returnUrl || !terms || !(Number(cappedAmount) > 0)) {
5551
+ throw new Error("createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount");
5204
5552
  }
5553
+ ;
5554
+ const data2 = await adminFetch({
5555
+ fetcher,
5556
+ adminAccessToken,
5557
+ domain,
5558
+ query: `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
5559
+ appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
5560
+ appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
5561
+ confirmationUrl
5562
+ userErrors { field message }
5563
+ }
5564
+ }`,
5565
+ variables: {
5566
+ lineItems: [
5567
+ {
5568
+ plan: {
5569
+ appRecurringPricingDetails: {
5570
+ interval: "EVERY_30_DAYS",
5571
+ price: { amount: Number(recurringPrice), currencyCode }
5572
+ }
5573
+ }
5574
+ },
5575
+ {
5576
+ plan: {
5577
+ appUsagePricingDetails: {
5578
+ cappedAmount: { amount: Number(cappedAmount), currencyCode },
5579
+ terms
5580
+ }
5581
+ }
5582
+ }
5583
+ ],
5584
+ name,
5585
+ returnUrl,
5586
+ test
5587
+ }
5588
+ });
5589
+ const payload = data2 == null ? void 0 : data2.appSubscriptionCreate;
5590
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5591
+ if (userErrors.length) {
5592
+ throw new Error("Shopify subscription create failed: " + userErrors.map((entry) => entry.message).join("; "));
5593
+ }
5594
+ ;
5595
+ if (!(payload == null ? void 0 : payload.confirmationUrl)) {
5596
+ throw new Error("Shopify subscription create failed: no confirmation url returned");
5597
+ }
5598
+ ;
5599
+ return {
5600
+ confirmationUrl: payload.confirmationUrl,
5601
+ subscription: payload.appSubscription
5602
+ };
5205
5603
  };
5206
- var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
5207
- "customers/data_request",
5208
- "customers/redact",
5209
- "shop/redact"
5210
- ]);
5211
- var shopify_default2 = {
5212
- // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
5213
- // a BUTTON LABEL inside the copy object next to a url — the title and the
5214
- // address it belongs to sat at different levels and could drift apart.
5215
- //
5216
- // It names where the link GOES rather than what it does: installing happens on
5217
- // the App Store listing, and the dashboard must never imply a store can be
5218
- // linked from inside it.
5219
- actions: {
5220
- create: null,
5221
- listing: "View on the Shopify App Store",
5222
- manage: null,
5223
- update: null
5224
- },
5225
- // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
5226
- // sees a consent screen we sent them to -- they start at the App Store, and
5227
- // the install completes inside Shopify admin without redirecting back. A
5228
- // Connect button here would be lying about where connecting happens.
5229
- auth: {
5230
- // ONE STORE PER ORGANIZATION. A Shopify install IS a store, and a second
5231
- // one would give an organization two catalogues, two order streams and two
5232
- // usage meters with nothing to say which is authoritative.
5233
- //
5234
- // DECLARED AND ENFORCED BY NOTHING, measured 2026-09-13: no repo reads this
5235
- // key, and the connection collection's { organization, slug } index is not
5604
+ var createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = "USD", description, domain, idempotencyKey, subscriptionLineItemId }) => {
5605
+ var _a;
5606
+ if (!subscriptionLineItemId || !description || !(Number(amount) > 0)) {
5607
+ throw new Error("createUsageRecord requires subscriptionLineItemId, description, and a positive amount");
5608
+ }
5609
+ ;
5610
+ const data2 = await adminFetch({
5611
+ fetcher,
5612
+ adminAccessToken,
5613
+ domain,
5614
+ query: `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
5615
+ appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
5616
+ appUsageRecord { id }
5617
+ userErrors { field message }
5618
+ }
5619
+ }`,
5620
+ variables: {
5621
+ description,
5622
+ idempotencyKey: idempotencyKey ? String(idempotencyKey) : null,
5623
+ price: { amount: Number(amount), currencyCode },
5624
+ subscriptionLineItemId
5625
+ }
5626
+ });
5627
+ const payload = data2 == null ? void 0 : data2.appUsageRecordCreate;
5628
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5629
+ if (userErrors.length || !((_a = payload == null ? void 0 : payload.appUsageRecord) == null ? void 0 : _a.id)) {
5630
+ throw new Error("Shopify usage record failed: " + (userErrors.map((entry) => entry.message).join("; ") || "no record returned"));
5631
+ }
5632
+ ;
5633
+ return { id: payload.appUsageRecord.id };
5634
+ };
5635
+ var getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
5636
+ var _a, _b;
5637
+ const data2 = await adminFetch({
5638
+ fetcher,
5639
+ adminAccessToken,
5640
+ domain,
5641
+ query: `{
5642
+ product( id : "${productId}" ) {
5643
+ variants( first : 100 ) {
5644
+ edges {
5645
+ node {
5646
+ id
5647
+ inventoryQuantity
5648
+ }
5649
+ }
5650
+ }
5651
+ }
5652
+ }`
5653
+ });
5654
+ const edges = ((_b = (_a = data2 == null ? void 0 : data2.product) == null ? void 0 : _a.variants) == null ? void 0 : _b.edges) || [];
5655
+ return edges.reduce((accumulator, edge) => {
5656
+ var _a2, _b2;
5657
+ const id = (_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id;
5658
+ const quantity = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.inventoryQuantity;
5659
+ if (id) {
5660
+ accumulator[id] = Number.isFinite(quantity) ? quantity : 0;
5661
+ }
5662
+ ;
5663
+ return accumulator;
5664
+ }, {});
5665
+ };
5666
+ var getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
5667
+ var _a;
5668
+ const data2 = await adminFetch({
5669
+ fetcher,
5670
+ adminAccessToken,
5671
+ domain,
5672
+ query: `
5673
+ query ProductUpdatedAt($id: ID!) {
5674
+ product(id: $id) {
5675
+ updatedAt
5676
+ }
5677
+ }
5678
+ `,
5679
+ variables: { id: productId }
5680
+ });
5681
+ return ((_a = data2 == null ? void 0 : data2.product) == null ? void 0 : _a.updatedAt) || null;
5682
+ };
5683
+ var NOT_ON_CHANNEL = "not available to the channel";
5684
+ var OUTDATED_FEEDBACK = "later version";
5685
+ var sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
5686
+ var _a, _b, _c;
5687
+ const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
5688
+ if (!productUpdatedAt) return null;
5689
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5690
+ const submit = async (version) => {
5691
+ const data2 = await adminFetch({
5692
+ fetcher,
5693
+ adminAccessToken,
5694
+ domain,
5695
+ query: `
5696
+ mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
5697
+ bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
5698
+ feedback {
5699
+ productId
5700
+ state
5701
+ }
5702
+ userErrors {
5703
+ field
5704
+ message
5705
+ }
5706
+ }
5707
+ }
5708
+ `,
5709
+ variables: {
5710
+ feedbackInput: [
5711
+ {
5712
+ productId,
5713
+ state,
5714
+ feedbackGeneratedAt: now,
5715
+ productUpdatedAt: version,
5716
+ messages
5717
+ }
5718
+ ]
5719
+ }
5720
+ });
5721
+ return data2 == null ? void 0 : data2.bulkProductResourceFeedbackCreate;
5722
+ };
5723
+ let result = await submit(productUpdatedAt);
5724
+ if ((((_b = (_a = result == null ? void 0 : result.userErrors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) || "").toLowerCase().includes(OUTDATED_FEEDBACK)) {
5725
+ result = await submit(now);
5726
+ }
5727
+ ;
5728
+ const feedbackErrors = result == null ? void 0 : result.userErrors;
5729
+ if (feedbackErrors == null ? void 0 : feedbackErrors.length) {
5730
+ const message = feedbackErrors[0].message || "";
5731
+ if (message.toLowerCase().includes(NOT_ON_CHANNEL)) return null;
5732
+ throw new Error("Shopify product feedback failed: " + message);
5733
+ }
5734
+ ;
5735
+ return ((_c = result == null ? void 0 : result.feedback) == null ? void 0 : _c[0]) || null;
5736
+ };
5737
+
5738
+ // lib/shopify/billing.js
5739
+ var billing_exports = {};
5740
+ __export(billing_exports, {
5741
+ sendAppEvent: () => sendAppEvent
5742
+ });
5743
+ var APP_API = "https://api.shopify.com";
5744
+ var APP_API_VERSION = process.env.SHOPIFY_APP_API_VERSION || SHOPIFY_APP_API_VERSION;
5745
+ var cachedTokens = {};
5746
+ var fetchAppToken = async ({ clientId, clientSecret, fetcher }) => {
5747
+ const data2 = await request({
5748
+ fetcher,
5749
+ method: "POST",
5750
+ url: APP_API + "/auth/access_token",
5751
+ body: {
5752
+ client_id: clientId,
5753
+ client_secret: clientSecret,
5754
+ grant_type: "client_credentials"
5755
+ }
5756
+ });
5757
+ if (!(data2 == null ? void 0 : data2.access_token)) {
5758
+ throw new Error("Shopify app token request failed: no access token returned");
5759
+ }
5760
+ ;
5761
+ const ttl = data2.expires_in ? data2.expires_in * 1e3 : 60 * 60 * 1e3;
5762
+ cachedTokens[clientId] = {
5763
+ expiresAt: Date.now() + ttl - 60 * 1e3,
5764
+ token: data2.access_token
5765
+ };
5766
+ return data2.access_token;
5767
+ };
5768
+ var getAppToken = async ({ clientId, clientSecret, fetcher }) => {
5769
+ const cached = cachedTokens[clientId];
5770
+ if (cached && cached.expiresAt > Date.now()) {
5771
+ return cached.token;
5772
+ }
5773
+ ;
5774
+ return fetchAppToken({ clientId, clientSecret, fetcher });
5775
+ };
5776
+ var toShopGid = (shopId) => String(shopId).startsWith("gid://") ? String(shopId) : "gid://shopify/Shop/" + shopId;
5777
+ var sendAppEvent = async ({ fetcher, clientId, clientSecret, eventHandle, idempotencyKey, reference: reference2, revision, shopId, timestamp, value }) => {
5778
+ if (!shopId || !eventHandle || !(Number(value) > 0)) {
5779
+ throw new Error("sendAppEvent requires shopId, eventHandle, and a positive value");
5780
+ }
5781
+ ;
5782
+ if (!clientId || !clientSecret) {
5783
+ throw new Error("sendAppEvent requires clientId and clientSecret");
5784
+ }
5785
+ ;
5786
+ const suffix = revision ? ".r" + revision : "";
5787
+ const key = idempotencyKey && String(idempotencyKey).slice(0, Math.max(0, 64 - suffix.length)) + suffix;
5788
+ const body = {
5789
+ attributes: {
5790
+ value: Number(value),
5791
+ ...reference2 && {
5792
+ reference: String(reference2).slice(0, 128)
5793
+ }
5794
+ },
5795
+ event_handle: eventHandle,
5796
+ shop_id: toShopGid(shopId),
5797
+ timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString(),
5798
+ ...key && {
5799
+ idempotency_key: key
5800
+ }
5801
+ };
5802
+ const send2 = async (token) => request({
5803
+ method: "POST",
5804
+ url: APP_API + "/app/" + APP_API_VERSION + "/events",
5805
+ headers: {
5806
+ "Authorization": "Bearer " + token
5807
+ },
5808
+ body
5809
+ });
5810
+ try {
5811
+ return await send2(await getAppToken({ clientId, clientSecret, fetcher }));
5812
+ } catch (error) {
5813
+ if ((error == null ? void 0 : error.status) === 401) {
5814
+ delete cachedTokens[clientId];
5815
+ return send2(await fetchAppToken({ clientId, clientSecret, fetcher }));
5816
+ }
5817
+ ;
5818
+ throw error;
5819
+ }
5820
+ };
5821
+
5822
+ // lib/shopify/oauth.js
5823
+ var oauth_exports = {};
5824
+ __export(oauth_exports, {
5825
+ SHOPIFY_REQUIRED_SCOPES: () => SHOPIFY_REQUIRED_SCOPES,
5826
+ createStorefrontToken: () => createStorefrontToken,
5827
+ getAdminToken: () => getAdminToken,
5828
+ getShop: () => getShop,
5829
+ missingScopes: () => missingScopes2,
5830
+ ping: () => ping,
5831
+ refreshAdminToken: () => refreshAdminToken,
5832
+ resolveConnectionSettings: () => resolveConnectionSettings
5833
+ });
5834
+ var required = (name, value) => {
5835
+ if (!value) {
5836
+ throw new Error("Shopify " + name + " is required");
5837
+ }
5838
+ ;
5839
+ return value;
5840
+ };
5841
+ var missingScopes2 = (granted) => {
5842
+ const grantedSet = new Set(
5843
+ (Array.isArray(granted) ? granted : String(granted || "").split(",")).map((scope) => scope.trim()).filter(Boolean)
5844
+ );
5845
+ const satisfied = (scope) => grantedSet.has(scope) || grantedSet.has(scope.replace(/(^|_)read_/, "$1write_"));
5846
+ return SHOPIFY_REQUIRED_SCOPES.filter((scope) => !satisfied(scope));
5847
+ };
5848
+ var ping = async ({ fetcher, adminAccessToken, domain }) => {
5849
+ var _a;
5850
+ const response = await fetch(
5851
+ `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`,
5852
+ {
5853
+ body: JSON.stringify({ query: "{ shop { name } }" }),
5854
+ headers: {
5855
+ "Content-Type": "application/json",
5856
+ "X-Shopify-Access-Token": adminAccessToken
5857
+ },
5858
+ method: "POST"
5859
+ }
5860
+ );
5861
+ if (!response.ok) {
5862
+ const text = await response.text().catch(() => "");
5863
+ const error = new Error(response.status + ": " + text);
5864
+ error.status = response.status;
5865
+ throw error;
5866
+ }
5867
+ ;
5868
+ const { errors } = await response.json().catch(() => ({}));
5869
+ if (errors == null ? void 0 : errors.length) {
5870
+ throw new Error("GraphQL: " + (((_a = errors[0]) == null ? void 0 : _a.message) || "unknown error"));
5871
+ }
5872
+ ;
5873
+ };
5874
+ var OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
5875
+ var parseOAuthErrorCode = (text) => {
5876
+ var _a;
5877
+ try {
5878
+ const parsed = JSON.parse(text);
5879
+ if (parsed == null ? void 0 : parsed.error) return parsed.error;
5880
+ } catch {
5881
+ }
5882
+ ;
5883
+ return ((_a = text.match(OAUTH_ERROR_TITLE_PATTERN)) == null ? void 0 : _a[1]) || null;
5884
+ };
5885
+ var shopifyOAuthFetch = async (url, body) => {
5886
+ const response = await fetch(url, {
5887
+ method: "POST",
5888
+ headers: {
5889
+ "Content-Type": "application/json"
5890
+ },
5891
+ body: JSON.stringify(body)
5892
+ });
5893
+ if (!response.ok) {
5894
+ const text = await response.text().catch(() => "");
5895
+ const error = new Error(response.status + ": " + text);
5896
+ error.status = response.status;
5897
+ error.code = parseOAuthErrorCode(text);
5898
+ throw error;
5899
+ }
5900
+ ;
5901
+ return response.json();
5902
+ };
5903
+ var getShop = async ({ fetcher, adminAccessToken, shop }) => {
5904
+ const data2 = await adminFetch({
5905
+ fetcher,
5906
+ adminAccessToken,
5907
+ domain: shop,
5908
+ query: `{
5909
+ shop {
5910
+ currencyCode
5911
+ id
5912
+ myshopifyDomain
5913
+ name
5914
+ }
5915
+ }`
5916
+ });
5917
+ const shopData = data2 == null ? void 0 : data2.shop;
5918
+ if (!(shopData == null ? void 0 : shopData.id)) {
5919
+ throw new Error("Shopify shop lookup failed: no shop returned");
5920
+ }
5921
+ ;
5922
+ return {
5923
+ currency: shopData.currencyCode || null,
5924
+ id: shopData.id.split("/").pop(),
5925
+ myshopifyDomain: shopData.myshopifyDomain,
5926
+ name: shopData.name
5927
+ };
5928
+ };
5929
+ var createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
5930
+ var _a, _b, _c, _d, _e;
5931
+ try {
5932
+ const listed = await adminFetch({
5933
+ fetcher,
5934
+ adminAccessToken,
5935
+ domain: shop,
5936
+ query: `{
5937
+ shop {
5938
+ storefrontAccessTokens(first: 100) {
5939
+ edges {
5940
+ node {
5941
+ id
5942
+ title
5943
+ }
5944
+ }
5945
+ }
5946
+ }
5947
+ }`
5948
+ });
5949
+ 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);
5950
+ const orphaned = existing.filter((token) => (token == null ? void 0 : token.title) === "Drawbridge");
5951
+ await Promise.allSettled(
5952
+ orphaned.map((token) => adminFetch({
5953
+ adminAccessToken,
5954
+ domain: shop,
5955
+ query: `
5956
+ mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
5957
+ storefrontAccessTokenDelete(input: $input) {
5958
+ deletedStorefrontAccessTokenId
5959
+ userErrors {
5960
+ field
5961
+ message
5962
+ }
5963
+ }
5964
+ }
5965
+ `,
5966
+ variables: {
5967
+ input: {
5968
+ id: token.id
5969
+ }
5970
+ }
5971
+ }))
5972
+ );
5973
+ } catch {
5974
+ }
5975
+ const created = await adminFetch({
5976
+ fetcher,
5977
+ adminAccessToken,
5978
+ domain: shop,
5979
+ query: `
5980
+ mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
5981
+ storefrontAccessTokenCreate(input: $input) {
5982
+ storefrontAccessToken {
5983
+ accessToken
5984
+ }
5985
+ userErrors {
5986
+ field
5987
+ message
5988
+ }
5989
+ }
5990
+ }
5991
+ `,
5992
+ variables: {
5993
+ input: {
5994
+ title: "Drawbridge"
5995
+ }
5996
+ }
5997
+ });
5998
+ const createErrors = (_c = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _c.userErrors;
5999
+ if (createErrors == null ? void 0 : createErrors.length) {
6000
+ throw new Error("Shopify storefront token create failed: " + createErrors[0].message);
6001
+ }
6002
+ ;
6003
+ return ((_e = (_d = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _d.storefrontAccessToken) == null ? void 0 : _e.accessToken) || null;
6004
+ };
6005
+ var resolveTokenContext = async ({ connection: connection2, controller }) => {
6006
+ const decrypted = (connection2 == null ? void 0 : connection2.settings) ? decrypt(connection2.settings) : {};
6007
+ if ((decrypted == null ? void 0 : decrypted.ref) !== "shop") {
6008
+ return {
6009
+ raw: (connection2 == null ? void 0 : connection2.settings) || null,
6010
+ session: null,
6011
+ settings: decrypted,
6012
+ shop: null,
6013
+ target: "connection"
6014
+ };
6015
+ }
6016
+ ;
6017
+ const shop = decrypted.shop;
6018
+ if (!controller || !shop) {
6019
+ return { raw: null, session: {}, settings: {}, shop, target: "shop" };
6020
+ }
6021
+ ;
6022
+ const record = await controller.get({ collection: "shop", query: { shop } });
6023
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : {};
6024
+ const storefront = (record == null ? void 0 : record.storefront) ? decrypt(record.storefront) : null;
6025
+ return {
6026
+ raw: (record == null ? void 0 : record.settings) || null,
6027
+ session,
6028
+ settings: {
6029
+ adminAccessToken: session.accessToken || null,
6030
+ refreshToken: session.refreshToken || null,
6031
+ refreshTokenExpiresAt: session.refreshTokenExpires || null,
6032
+ storefrontAccessToken: (storefront == null ? void 0 : storefront.token) || null,
6033
+ tokenExpiresAt: session.expires || null
6034
+ },
6035
+ shop,
6036
+ target: "shop"
6037
+ };
6038
+ };
6039
+ var resolveConnectionSettings = async ({ fetcher, connection: connection2, controller }) => {
6040
+ const { settings } = await resolveTokenContext({ connection: connection2, controller });
6041
+ return settings;
6042
+ };
6043
+ var refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection: connection2, controller }) => {
6044
+ var _a;
6045
+ const { raw, session, settings, shop, target } = await resolveTokenContext({ connection: connection2, controller });
6046
+ const domain = (connection2 == null ? void 0 : connection2.shop) || ((_a = settings.source) == null ? void 0 : _a.domain) || settings.domain || shop;
6047
+ const { refreshToken } = settings;
6048
+ const data2 = await shopifyOAuthFetch(
6049
+ `https://${domain}/admin/oauth/access_token`,
6050
+ {
6051
+ grant_type: "refresh_token",
6052
+ // Throw before the grant: rotation RETIRES the old pair the moment it
6053
+ // returns, so a credential-less call that Shopify rejects opaquely is
6054
+ // the one failure mode worth being loud about.
6055
+ client_id: required("clientId", clientId),
6056
+ client_secret: required("clientSecret", clientSecret),
6057
+ refresh_token: refreshToken
6058
+ }
6059
+ );
6060
+ const adminAccessToken = data2.access_token;
6061
+ const newRefreshToken = data2.refresh_token;
6062
+ const expiresIn = data2.expires_in;
6063
+ const tokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : null;
6064
+ const refreshTokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS);
6065
+ let liveToken = adminAccessToken;
6066
+ if (target === "shop") {
6067
+ const written = await controller.update({
6068
+ collection: "shop",
6069
+ data: {
6070
+ $set: {
6071
+ settings: encrypt({
6072
+ ...session,
6073
+ accessToken: adminAccessToken,
6074
+ expires: tokenExpiresAt,
6075
+ refreshToken: newRefreshToken,
6076
+ refreshTokenExpires: refreshTokenExpiresAt
6077
+ })
6078
+ }
6079
+ },
6080
+ query: {
6081
+ shop,
6082
+ ...raw && { settings: raw }
6083
+ }
6084
+ });
6085
+ if (!written) {
6086
+ const current = await controller.get({ collection: "shop", query: { shop } });
6087
+ if (!(current == null ? void 0 : current.settings)) {
6088
+ throw new Error("Shopify token refresh write-back failed: shop record missing for " + shop);
6089
+ }
6090
+ ;
6091
+ const currentSession = decrypt(current.settings);
6092
+ if ((currentSession == null ? void 0 : currentSession.accessToken) && currentSession.accessToken !== settings.adminAccessToken) {
6093
+ liveToken = currentSession.accessToken;
6094
+ } else {
6095
+ throw new Error("Shopify token refresh write-back failed for " + shop);
6096
+ }
6097
+ ;
6098
+ }
6099
+ ;
6100
+ } else {
6101
+ const written = await controller.update({
6102
+ collection: "connection",
6103
+ data: {
6104
+ $set: {
6105
+ settings: encrypt({
6106
+ ...settings,
6107
+ adminAccessToken,
6108
+ refreshToken: newRefreshToken,
6109
+ refreshTokenExpiresAt,
6110
+ tokenExpiresAt
6111
+ })
6112
+ }
6113
+ },
6114
+ query: {
6115
+ id: connection2.id,
6116
+ ...raw && { settings: raw }
6117
+ }
6118
+ });
6119
+ if (!written) {
6120
+ const current = await controller.get({ collection: "connection", query: { id: connection2.id } });
6121
+ const currentSettings = (current == null ? void 0 : current.settings) ? decrypt(current.settings) : null;
6122
+ if ((currentSettings == null ? void 0 : currentSettings.adminAccessToken) && currentSettings.adminAccessToken !== settings.adminAccessToken) {
6123
+ liveToken = currentSettings.adminAccessToken;
6124
+ } else {
6125
+ throw new Error("Shopify token refresh write-back failed for connection " + (connection2 == null ? void 0 : connection2.id));
6126
+ }
6127
+ ;
6128
+ }
6129
+ ;
6130
+ }
6131
+ ;
6132
+ await ping({ adminAccessToken: liveToken, domain });
6133
+ return liveToken;
6134
+ };
6135
+ var getAdminToken = async ({ fetcher, clientId, clientSecret, connection: connection2, controller }) => {
6136
+ required("clientId", clientId);
6137
+ required("clientSecret", clientSecret);
6138
+ const settings = await resolveConnectionSettings({ connection: connection2, controller });
6139
+ const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
6140
+ if (!adminAccessToken) {
6141
+ return null;
6142
+ }
6143
+ ;
6144
+ if (!refreshToken) {
6145
+ return adminAccessToken;
6146
+ }
6147
+ ;
6148
+ const needsRefresh = !tokenExpiresAt || new Date(tokenExpiresAt) < new Date(Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS);
6149
+ if (needsRefresh) {
6150
+ return refreshAdminToken({ clientId, clientSecret, connection: connection2, controller });
6151
+ }
6152
+ ;
6153
+ return adminAccessToken;
6154
+ };
6155
+
6156
+ // lib/shopify/partner.js
6157
+ var partner_exports = {};
6158
+ __export(partner_exports, {
6159
+ getUsageChargeEvents: () => getUsageChargeEvents
6160
+ });
6161
+ var PARTNER_API = "https://partners.shopify.com";
6162
+ var getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
6163
+ var _a, _b, _c;
6164
+ if (!partnerToken || !organizationId || !appId) {
6165
+ throw new Error("getUsageChargeEvents requires partnerToken, organizationId, and appId");
6166
+ }
6167
+ ;
6168
+ const data2 = await request({
6169
+ fetcher,
6170
+ method: "POST",
6171
+ url: PARTNER_API + "/" + organizationId + "/api/" + PARTNER_API_VERSION + "/graphql.json",
6172
+ headers: {
6173
+ "X-Shopify-Access-Token": partnerToken
6174
+ },
6175
+ body: {
6176
+ query: `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
6177
+ events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
6178
+ edges {
6179
+ node {
6180
+ id
6181
+ occurredAt
6182
+ eventType
6183
+ shop { id myshopifyDomain }
6184
+ ... on Charge {
6185
+ chargeId
6186
+ chargeType
6187
+ amount { amount currencyCode }
6188
+ usageQuantity
6189
+ planHandle
6190
+ description
6191
+ }
6192
+ }
6193
+ }
6194
+ pageInfo { hasNextPage endCursor }
6195
+ }
6196
+ }`,
6197
+ variables: {
6198
+ filter: {
6199
+ eventTypes: ["CHARGE_USAGE"],
6200
+ subjectId: String(appId),
6201
+ ...occurredAtMin && { occurredAtMin },
6202
+ ...shopId && { shopId: String(shopId) }
6203
+ },
6204
+ first
6205
+ }
6206
+ }
6207
+ });
6208
+ if ((_a = data2 == null ? void 0 : data2.errors) == null ? void 0 : _a.length) {
6209
+ throw new Error("Shopify partner events query failed: " + data2.errors.map((entry) => entry.message).join("; "));
6210
+ }
6211
+ ;
6212
+ return (((_c = (_b = data2 == null ? void 0 : data2.data) == null ? void 0 : _b.events) == null ? void 0 : _c.edges) || []).map((edge) => {
6213
+ var _a2, _b2, _c2, _d, _e, _f, _g, _h, _i, _j;
6214
+ return {
6215
+ amount: ((_a2 = edge.node) == null ? void 0 : _a2.amount) || null,
6216
+ chargeId: ((_b2 = edge.node) == null ? void 0 : _b2.chargeId) || null,
6217
+ // USAGE vs the recurring plan charge an approval emits — the
6218
+ // disambiguator callers filter on.
6219
+ chargeType: ((_c2 = edge.node) == null ? void 0 : _c2.chargeType) || null,
6220
+ description: ((_d = edge.node) == null ? void 0 : _d.description) || null,
6221
+ eventType: ((_e = edge.node) == null ? void 0 : _e.eventType) || null,
6222
+ id: ((_f = edge.node) == null ? void 0 : _f.id) || null,
6223
+ occurredAt: ((_g = edge.node) == null ? void 0 : _g.occurredAt) || null,
6224
+ planHandle: ((_h = edge.node) == null ? void 0 : _h.planHandle) || null,
6225
+ shop: ((_i = edge.node) == null ? void 0 : _i.shop) || null,
6226
+ usageQuantity: ((_j = edge.node) == null ? void 0 : _j.usageQuantity) ?? null
6227
+ };
6228
+ });
6229
+ };
6230
+
6231
+ // lib/shopify/storefront.js
6232
+ var storefront_exports = {};
6233
+ __export(storefront_exports, {
6234
+ cartCreate: () => cartCreate,
6235
+ cartGet: () => cartGet,
6236
+ cartLinesAdd: () => cartLinesAdd,
6237
+ cartLinesRemove: () => cartLinesRemove,
6238
+ cartLinesUpdate: () => cartLinesUpdate,
6239
+ createCheckoutToken: () => createCheckoutToken,
6240
+ getProduct: () => getProduct,
6241
+ getProductVariantsPage: () => getProductVariantsPage,
6242
+ getProducts: () => getProducts,
6243
+ probeVariantsOutOfStock: () => probeVariantsOutOfStock
6244
+ });
6245
+ var SORT_KEY_MAP = {
6246
+ bestSelling: "BEST_SELLING",
6247
+ createdAt: "CREATED_AT",
6248
+ id: "ID",
6249
+ price: "PRICE",
6250
+ productType: "PRODUCT_TYPE",
6251
+ title: "TITLE",
6252
+ updatedAt: "UPDATED_AT",
6253
+ vendor: "VENDOR"
6254
+ };
6255
+ var resolveProductSort = (sort) => {
6256
+ const entry = sort && Object.entries(sort)[0];
6257
+ if (!entry) return null;
6258
+ const [field2, direction] = entry;
6259
+ const sortKey = SORT_KEY_MAP[field2];
6260
+ if (!sortKey) return null;
6261
+ return {
6262
+ sortKey,
6263
+ reverse: Number(direction) < 0
6264
+ };
6265
+ };
6266
+ var storefrontUrl = (domain) => `https://${domain}/api/${SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`;
6267
+ var storefrontFetch = async ({ fetcher, domain, storefrontAccessToken, query, variables }) => {
6268
+ return request({
6269
+ method: "POST",
6270
+ url: storefrontUrl(domain),
6271
+ headers: {
6272
+ "X-Shopify-Storefront-Access-Token": storefrontAccessToken
6273
+ },
6274
+ body: {
6275
+ query,
6276
+ ...variables && { variables }
6277
+ }
6278
+ });
6279
+ };
6280
+ var shopCountryCache = {};
6281
+ var getShopCountry = async ({ domain, fetcher, storefrontAccessToken }) => {
6282
+ var _a, _b;
6283
+ if (shopCountryCache[domain]) return shopCountryCache[domain];
6284
+ const { data: data2 } = await storefrontFetch({
6285
+ fetcher,
6286
+ domain,
6287
+ storefrontAccessToken,
6288
+ query: `{
6289
+ shop {
6290
+ paymentSettings {
6291
+ countryCode
6292
+ }
6293
+ }
6294
+ }`
6295
+ });
6296
+ const countryCode = ((_b = (_a = data2 == null ? void 0 : data2.shop) == null ? void 0 : _a.paymentSettings) == null ? void 0 : _b.countryCode) || null;
6297
+ if (countryCode) shopCountryCache[domain] = countryCode;
6298
+ return countryCode;
6299
+ };
6300
+ var OUT_OF_STOCK_WARNING_CODES = [
6301
+ "MERCHANDISE_OUT_OF_STOCK",
6302
+ "MERCHANDISE_NOT_ENOUGH_STOCK"
6303
+ ];
6304
+ var probeVariantsOutOfStock = async ({ fetcher, domain, storefrontAccessToken, variantIds, countryCode }) => {
6305
+ var _a, _b;
6306
+ if (!(variantIds == null ? void 0 : variantIds.length)) return [];
6307
+ const resolvedCountry = countryCode || await getShopCountry({ domain, fetcher, storefrontAccessToken });
6308
+ const { data: data2, errors } = await storefrontFetch({
6309
+ fetcher,
6310
+ domain,
6311
+ storefrontAccessToken,
6312
+ query: `
6313
+ mutation probe( $input : CartInput! ){
6314
+ cartCreate( input : $input ){
6315
+ cart {
6316
+ lines( first : 250 ){
6317
+ edges {
6318
+ node {
6319
+ id
6320
+ merchandise {
6321
+ ... on ProductVariant {
6322
+ id
6323
+ }
6324
+ }
6325
+ }
6326
+ }
6327
+ }
6328
+ }
6329
+ warnings {
6330
+ code
6331
+ message
6332
+ target
6333
+ }
6334
+ }
6335
+ }
6336
+ `,
6337
+ variables: {
6338
+ input: {
6339
+ lines: variantIds.map((merchandiseId) => ({
6340
+ merchandiseId,
6341
+ quantity: 1
6342
+ })),
6343
+ ...resolvedCountry && {
6344
+ buyerIdentity: {
6345
+ countryCode: resolvedCountry
6346
+ }
6347
+ }
6348
+ }
6349
+ }
6350
+ });
6351
+ if (errors) return [];
6352
+ const payload = data2 == null ? void 0 : data2.cartCreate;
6353
+ const soldOut = new Set(
6354
+ ((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)
6355
+ );
6356
+ 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 }) => {
6357
+ var _a2;
6358
+ return (_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.id;
6359
+ }).filter(Boolean);
6360
+ };
6361
+ var productsQuery = (search, cursor, limit, sort) => {
6362
+ const resolved = resolveProductSort(sort);
6363
+ const sortArgs = resolved ? `, sortKey: ${resolved.sortKey}, reverse: ${resolved.reverse}` : "";
6364
+ return `{
6365
+ products(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}${sortArgs}) {
6366
+ edges {
6367
+ node {
6368
+ id
6369
+ title
6370
+ description
6371
+ handle
6372
+ productType
6373
+ featuredImage {
6374
+ url
6375
+ }
6376
+ variants(first: 10) {
6377
+ edges {
6378
+ node {
6379
+ id
6380
+ title
6381
+ image {
6382
+ url
6383
+ }
6384
+ price {
6385
+ amount
6386
+ currencyCode
6387
+ }
6388
+ compareAtPrice {
6389
+ amount
6390
+ currencyCode
6391
+ }
6392
+ availableForSale
6393
+ requiresShipping
6394
+ }
6395
+ }
6396
+ }
6397
+ }
6398
+ }
6399
+ pageInfo {
6400
+ endCursor
6401
+ hasNextPage
6402
+ hasPreviousPage
6403
+ startCursor
6404
+ }
6405
+ }
6406
+ }`;
6407
+ };
6408
+ var productQuery = (productId) => `{
6409
+ product(id: "${productId}") {
6410
+ id
6411
+ title
6412
+ description
6413
+ handle
6414
+ productType
6415
+ featuredImage {
6416
+ url
6417
+ }
6418
+ variants(first: 10) {
6419
+ edges {
6420
+ node {
6421
+ id
6422
+ title
6423
+ image {
6424
+ url
6425
+ }
6426
+ price {
6427
+ amount
6428
+ currencyCode
6429
+ }
6430
+ compareAtPrice {
6431
+ amount
6432
+ currencyCode
6433
+ }
6434
+ availableForSale
6435
+ requiresShipping
6436
+ }
6437
+ }
6438
+ }
6439
+ }
6440
+ }`;
6441
+ var variantsQuery = (productId, cursor) => `{
6442
+ product(id: "${productId}") {
6443
+ variants(first: 250${cursor ? `, after: "${cursor}"` : ""}) {
6444
+ edges {
6445
+ node {
6446
+ id
6447
+ title
6448
+ image {
6449
+ url
6450
+ }
6451
+ price {
6452
+ amount
6453
+ currencyCode
6454
+ }
6455
+ compareAtPrice {
6456
+ amount
6457
+ currencyCode
6458
+ }
6459
+ availableForSale
6460
+ requiresShipping
6461
+ }
6462
+ }
6463
+ pageInfo {
6464
+ hasNextPage
6465
+ endCursor
6466
+ }
6467
+ }
6468
+ }
6469
+ }`;
6470
+ var getProducts = async ({ fetcher, domain, storefrontAccessToken, search, cursor, limit, sort }) => {
6471
+ const { data: data2, errors } = await storefrontFetch({
6472
+ fetcher,
6473
+ domain,
6474
+ storefrontAccessToken,
6475
+ query: productsQuery(search, cursor, limit, sort)
6476
+ });
6477
+ if (errors) {
6478
+ throw new Error("Failed to fetch Shopify products");
6479
+ }
6480
+ ;
6481
+ return data2 == null ? void 0 : data2.products;
6482
+ };
6483
+ var getProduct = async ({ fetcher, domain, storefrontAccessToken, productId }) => {
6484
+ var _a;
6485
+ const { data: data2, errors } = await storefrontFetch({
6486
+ fetcher,
6487
+ domain,
6488
+ storefrontAccessToken,
6489
+ query: productQuery(productId)
6490
+ });
6491
+ if (errors) {
6492
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6493
+ }
6494
+ ;
6495
+ if (!(data2 == null ? void 0 : data2.product)) {
6496
+ throw new Error("Shopify product not found");
6497
+ }
6498
+ ;
6499
+ return data2.product;
6500
+ };
6501
+ var getProductVariantsPage = async ({ fetcher, domain, storefrontAccessToken, productId, cursor }) => {
6502
+ var _a;
6503
+ const { data: data2, errors } = await storefrontFetch({
6504
+ fetcher,
6505
+ domain,
6506
+ storefrontAccessToken,
6507
+ query: variantsQuery(productId, cursor)
6508
+ });
6509
+ if (errors) {
6510
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6511
+ }
6512
+ ;
6513
+ if (!(data2 == null ? void 0 : data2.product)) {
6514
+ throw new Error("Shopify product not found");
6515
+ }
6516
+ ;
6517
+ return data2.product.variants;
6518
+ };
6519
+ var CART_FIELDS = `
6520
+ id
6521
+ checkoutUrl
6522
+ lines(first: 100) {
6523
+ edges {
6524
+ node {
6525
+ id
6526
+ quantity
6527
+ merchandise {
6528
+ ... on ProductVariant {
6529
+ id
6530
+ title
6531
+ availableForSale
6532
+ image {
6533
+ url
6534
+ }
6535
+ price {
6536
+ amount
6537
+ currencyCode
6538
+ }
6539
+ product {
6540
+ title
6541
+ }
6542
+ }
6543
+ }
6544
+ }
6545
+ }
6546
+ }
6547
+ `;
6548
+ var mapCart = (cart) => {
6549
+ var _a;
6550
+ if (!cart) return null;
6551
+ return {
6552
+ checkoutUrl: cart.checkoutUrl,
6553
+ id: cart.id,
6554
+ lines: (((_a = cart.lines) == null ? void 0 : _a.edges) || []).map(({ node }) => {
6555
+ var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
6556
+ return {
6557
+ availableForSale: ((_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.availableForSale) ?? true,
6558
+ currency: ((_c = (_b = node == null ? void 0 : node.merchandise) == null ? void 0 : _b.price) == null ? void 0 : _c.currencyCode) || null,
6559
+ id: node.id,
6560
+ image: ((_e = (_d = node == null ? void 0 : node.merchandise) == null ? void 0 : _d.image) == null ? void 0 : _e.url) || null,
6561
+ price: ((_g = (_f = node == null ? void 0 : node.merchandise) == null ? void 0 : _f.price) == null ? void 0 : _g.amount) || null,
6562
+ productTitle: ((_i = (_h = node == null ? void 0 : node.merchandise) == null ? void 0 : _h.product) == null ? void 0 : _i.title) || null,
6563
+ quantity: node.quantity,
6564
+ variantId: (_j = node == null ? void 0 : node.merchandise) == null ? void 0 : _j.id,
6565
+ variantTitle: ((_k = node == null ? void 0 : node.merchandise) == null ? void 0 : _k.title) || null
6566
+ };
6567
+ })
6568
+ };
6569
+ };
6570
+ var firstUserError = (userErrors) => {
6571
+ const error = userErrors == null ? void 0 : userErrors[0];
6572
+ if (!error) return null;
6573
+ const message = new Error(error.message || "Shopify cart error");
6574
+ message.userError = true;
6575
+ return message;
6576
+ };
6577
+ var createCheckoutToken = async ({ fetcher, clientId, clientSecret } = {}) => {
6578
+ if (!clientId || !clientSecret) {
6579
+ throw new Error("createCheckoutToken requires clientId and clientSecret");
6580
+ }
6581
+ ;
6582
+ const response = await request({
6583
+ fetcher,
6584
+ method: "POST",
6585
+ url: "https://api.shopify.com/auth/access_token",
6586
+ body: {
6587
+ client_id: clientId,
6588
+ client_secret: clientSecret,
6589
+ grant_type: "client_credentials"
6590
+ }
6591
+ });
6592
+ const token = response == null ? void 0 : response.access_token;
6593
+ if (!token) {
6594
+ throw new Error("Failed to create Shopify checkout token");
6595
+ }
6596
+ ;
6597
+ return token;
6598
+ };
6599
+ var cartCreate = async ({ fetcher, domain, storefrontAccessToken, lines }) => {
6600
+ var _a, _b;
6601
+ const { data: data2, errors } = await storefrontFetch({
6602
+ fetcher,
6603
+ domain,
6604
+ storefrontAccessToken,
6605
+ query: `
6606
+ mutation cartCreate($input: CartInput!) {
6607
+ cartCreate(input: $input) {
6608
+ cart {
6609
+ ${CART_FIELDS}
6610
+ }
6611
+ userErrors {
6612
+ field
6613
+ message
6614
+ }
6615
+ }
6616
+ }
6617
+ `,
6618
+ variables: {
6619
+ input: {
6620
+ lines
6621
+ }
6622
+ }
6623
+ });
6624
+ if (errors) {
6625
+ throw new Error("Failed to create Shopify cart");
6626
+ }
6627
+ ;
6628
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartCreate) == null ? void 0 : _a.userErrors);
6629
+ if (userError) throw userError;
6630
+ return mapCart((_b = data2 == null ? void 0 : data2.cartCreate) == null ? void 0 : _b.cart);
6631
+ };
6632
+ var cartGet = async ({ fetcher, domain, storefrontAccessToken, cartId }) => {
6633
+ const { data: data2, errors } = await storefrontFetch({
6634
+ fetcher,
6635
+ domain,
6636
+ storefrontAccessToken,
6637
+ query: `
6638
+ query cart($id: ID!) {
6639
+ cart(id: $id) {
6640
+ ${CART_FIELDS}
6641
+ }
6642
+ }
6643
+ `,
6644
+ variables: {
6645
+ id: cartId
6646
+ }
6647
+ });
6648
+ if (errors) {
6649
+ throw new Error("Failed to fetch Shopify cart");
6650
+ }
6651
+ ;
6652
+ return mapCart(data2 == null ? void 0 : data2.cart);
6653
+ };
6654
+ var cartLinesAdd = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6655
+ var _a, _b;
6656
+ const { data: data2, errors } = await storefrontFetch({
6657
+ fetcher,
6658
+ domain,
6659
+ storefrontAccessToken,
6660
+ query: `
6661
+ mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
6662
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
6663
+ cart {
6664
+ ${CART_FIELDS}
6665
+ }
6666
+ userErrors {
6667
+ field
6668
+ message
6669
+ }
6670
+ }
6671
+ }
6672
+ `,
6673
+ variables: {
6674
+ cartId,
6675
+ lines
6676
+ }
6677
+ });
6678
+ if (errors) {
6679
+ throw new Error("Failed to add Shopify cart lines");
6680
+ }
6681
+ ;
6682
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesAdd) == null ? void 0 : _a.userErrors);
6683
+ if (userError) throw userError;
6684
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesAdd) == null ? void 0 : _b.cart);
6685
+ };
6686
+ var cartLinesRemove = async ({ fetcher, domain, storefrontAccessToken, cartId, lineIds }) => {
6687
+ var _a, _b;
6688
+ const { data: data2, errors } = await storefrontFetch({
6689
+ fetcher,
6690
+ domain,
6691
+ storefrontAccessToken,
6692
+ query: `
6693
+ mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
6694
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
6695
+ cart {
6696
+ ${CART_FIELDS}
6697
+ }
6698
+ userErrors {
6699
+ field
6700
+ message
6701
+ }
6702
+ }
6703
+ }
6704
+ `,
6705
+ variables: {
6706
+ cartId,
6707
+ lineIds
6708
+ }
6709
+ });
6710
+ if (errors) {
6711
+ throw new Error("Failed to remove Shopify cart lines");
6712
+ }
6713
+ ;
6714
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesRemove) == null ? void 0 : _a.userErrors);
6715
+ if (userError) throw userError;
6716
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesRemove) == null ? void 0 : _b.cart);
6717
+ };
6718
+ var cartLinesUpdate = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6719
+ var _a, _b;
6720
+ const { data: data2, errors } = await storefrontFetch({
6721
+ fetcher,
6722
+ domain,
6723
+ storefrontAccessToken,
6724
+ query: `
6725
+ mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
6726
+ cartLinesUpdate(cartId: $cartId, lines: $lines) {
6727
+ cart {
6728
+ ${CART_FIELDS}
6729
+ }
6730
+ userErrors {
6731
+ field
6732
+ message
6733
+ }
6734
+ }
6735
+ }
6736
+ `,
6737
+ variables: {
6738
+ cartId,
6739
+ lines
6740
+ }
6741
+ });
6742
+ if (errors) {
6743
+ throw new Error("Failed to update Shopify cart lines");
6744
+ }
6745
+ ;
6746
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesUpdate) == null ? void 0 : _a.userErrors);
6747
+ if (userError) throw userError;
6748
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesUpdate) == null ? void 0 : _b.cart);
6749
+ };
6750
+
6751
+ // lib/email.js
6752
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
6753
+ var toCanonicalEmail = (value) => {
6754
+ if (!value || typeof value !== "string") return null;
6755
+ const email = value.trim().toLowerCase();
6756
+ const at = email.lastIndexOf("@");
6757
+ if (at < 1 || at === email.length - 1) return null;
6758
+ let local = email.slice(0, at);
6759
+ const domain = email.slice(at + 1);
6760
+ const plus = local.indexOf("+");
6761
+ if (plus > 0) local = local.slice(0, plus);
6762
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
6763
+ if (!local) return null;
6764
+ return local + "@" + domain;
6765
+ };
6766
+
6767
+ // lib/oauth/index.js
6768
+ var lifetimes = {
6769
+ access: 60 * 60 * 8,
6770
+ // 8 hours
6771
+ authorizationCode: 10 * 60,
6772
+ // 10 minutes
6773
+ code: 15 * 60,
6774
+ // 15 minutes (email OTC)
6775
+ refresh: 30 * 24 * 60 * 60
6776
+ // 30 days
6777
+ };
6778
+ var tokenTypes = {
6779
+ access: "access",
6780
+ oauthState: "oauth.state",
6781
+ pat: "pat",
6782
+ refresh: "refresh"
6783
+ };
6784
+ var scopes = [
6785
+ "profile:read",
6786
+ "profile:write",
6787
+ "organization:read",
6788
+ "organization:write",
6789
+ "campaigns:read",
6790
+ "campaigns:write",
6791
+ "contacts:read",
6792
+ "contacts:write",
6793
+ "workflows:read",
6794
+ "workflows:write",
6795
+ "connections:read",
6796
+ "connections:write",
6797
+ "billing:read",
6798
+ "billing:write",
6799
+ "admin"
6800
+ ];
6801
+ var developer = [
6802
+ "profile:read",
6803
+ "organization:read",
6804
+ "campaigns:read",
6805
+ "campaigns:write",
6806
+ "contacts:read",
6807
+ "contacts:write",
6808
+ "workflows:read",
6809
+ "workflows:write",
6810
+ "connections:read",
6811
+ "connections:write"
6812
+ ];
6813
+ var scopeLabels = {
6814
+ "profile:read": "Read own profile",
6815
+ "profile:write": "Update own profile",
6816
+ "organization:read": "Read organization info",
6817
+ "organization:write": "Manage organization",
6818
+ "campaigns:read": "Read campaigns",
6819
+ "campaigns:write": "Manage campaigns",
6820
+ "contacts:read": "Read contacts",
6821
+ "contacts:write": "Manage contacts",
6822
+ "workflows:read": "Read workflows",
6823
+ "workflows:write": "Manage workflows",
6824
+ "connections:read": "Read integrations",
6825
+ "connections:write": "Manage integrations",
6826
+ "billing:read": "Read billing",
6827
+ "billing:write": "Manage billing",
6828
+ admin: "Admin access"
6829
+ };
6830
+ var toOption = (scope) => ({
6831
+ key: scopeLabels[scope] || scope,
6832
+ value: scope
6833
+ });
6834
+ var developerOptions = developer.map(toOption);
6835
+ var scopeOptions = scopes.map(toOption);
6836
+ var hashToken = (raw) => hash(raw, process.env.HMAC_OAUTH_TOKEN_KEY);
6837
+
6838
+ // lib/sanitize.js
6839
+ import disposableDomains from "disposable-email-domains/index.js";
6840
+ var disposableBlacklist = new Set(disposableDomains.map((d) => d.toLowerCase()));
6841
+ var sanitizeDomain = (value, pattern) => {
6842
+ if (!value) return value;
6843
+ const cleaned = value.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
6844
+ if (pattern) {
6845
+ const match = cleaned.match(pattern);
6846
+ return match ? match[0] : cleaned;
6847
+ }
6848
+ return cleaned;
6849
+ };
6850
+
6851
+ // lib/slugify.js
6852
+ import slug from "slugify";
6853
+ var slugify = (value, nochars = false) => {
6854
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
6855
+ const replacement = nochars ? "" : "-";
6856
+ return slug(
6857
+ value,
6858
+ {
6859
+ remove,
6860
+ replacement,
6861
+ lower: true,
6862
+ trim: true
6863
+ }
6864
+ );
6865
+ };
6866
+
6867
+ // lib/connections/manifests/shopify.js
6868
+ var client = Object.freeze({
6869
+ admin: admin_exports,
6870
+ billing: billing_exports,
6871
+ oauth: oauth_exports,
6872
+ partner: partner_exports,
6873
+ storefront: storefront_exports
6874
+ });
6875
+ var toLine = ({
6876
+ price,
6877
+ product_id: productId,
6878
+ quantity,
6879
+ title,
6880
+ variant_id: variantId,
6881
+ variant_title: variantTitle
6882
+ }) => ({
6883
+ price: parseFloat(price) || 0,
6884
+ productId: productId ? "gid://shopify/Product/" + productId : null,
6885
+ quantity: quantity || 1,
6886
+ title: title || null,
6887
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
6888
+ variantTitle: variantTitle || null
6889
+ });
6890
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
6891
+ (acc, item) => {
6892
+ const attrs = (item.properties || []).reduce(
6893
+ (map, { name, value }) => {
6894
+ map[name] = value;
6895
+ return map;
6896
+ },
6897
+ {}
6898
+ );
6899
+ if (!attrs["_drwbrdg_ca"]) return acc;
6900
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
6901
+ const line = toLine(item);
6902
+ acc.attributedGross += line.price * line.quantity;
6903
+ acc.attributedLines.push(line);
6904
+ return acc;
6905
+ },
6906
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
6907
+ );
6908
+ var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
6909
+ var blockedReason = (discount) => {
6910
+ var _a;
6911
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
6912
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
6913
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
6914
+ 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.";
6915
+ }
6916
+ ;
6917
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
6918
+ return "This discount has reached its total usage limit.";
6919
+ }
6920
+ ;
6921
+ return null;
6922
+ };
6923
+ var discountWarning = (discount) => {
6924
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
6925
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
6926
+ }
6927
+ ;
6928
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
6929
+ return null;
6930
+ };
6931
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
6932
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
6933
+ var OAUTH_ERROR_SOURCE = "oauth";
6934
+ var BILLING_ERROR_SOURCE = "billing";
6935
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
6936
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
6937
+ var inbound = {
6938
+ headers: {
6939
+ event: "x-shopify-topic",
6940
+ id: "x-shopify-webhook-id",
6941
+ shop: "x-shopify-shop-domain",
6942
+ signature: "x-shopify-hmac-sha256"
6943
+ },
6944
+ signature: {
6945
+ algorithm: "sha256",
6946
+ encoding: "base64",
6947
+ secret: "SHOPIFY_API_SECRET"
6948
+ }
6949
+ };
6950
+ var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
6951
+ "customers/data_request",
6952
+ "customers/redact",
6953
+ "shop/redact"
6954
+ ]);
6955
+ var refusal = (message, status) => Object.assign(new Error(message), { status });
6956
+ var LINK_TOKEN_TTL_MS = 30 * 60 * 1e3;
6957
+ var CONNECTABLE_STATUSES = ["active", "unsubscribed"];
6958
+ var merchantOrganizations = ({ read, user }) => read.aggregate({
6959
+ collection: "organization",
6960
+ pipeline: [
6961
+ // A member qualifies only at `manage`; an owner always qualifies.
6962
+ {
6963
+ $lookup: {
6964
+ as: "membership",
6965
+ from: "member",
6966
+ let: { organization: "$id" },
6967
+ pipeline: [
6968
+ {
6969
+ $match: {
6970
+ $expr: { $eq: ["$organization", "$$organization"] },
6971
+ "capabilities.connections": "manage",
6972
+ status: "accepted",
6973
+ user
6974
+ }
6975
+ },
6976
+ { $limit: 1 },
6977
+ { $project: { _id: 0, organization: 1 } }
6978
+ ]
6979
+ }
6980
+ },
6981
+ {
6982
+ $match: {
6983
+ status: { $ne: "canceled" },
6984
+ $or: [{ owner: user }, { "membership.0": { $exists: true } }]
6985
+ }
6986
+ },
6987
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
6988
+ // the list can read the real plan and conversion rate. Without it every org
6989
+ // rendered as free tier.
6990
+ { $lookup: { as: "subscription", foreignField: "id", from: "subscription", localField: "subscription" } },
6991
+ { $unwind: { path: "$subscription", preserveNullAndEmptyArrays: true } },
6992
+ {
6993
+ $lookup: {
6994
+ as: "connection",
6995
+ foreignField: "organization",
6996
+ from: "connection",
6997
+ localField: "id",
6998
+ pipeline: [
6999
+ { $match: { slug: "shopify" } },
7000
+ { $limit: 1 },
7001
+ { $project: { _id: 0, id: 1, shop: 1, status: 1 } }
7002
+ ]
7003
+ }
7004
+ },
7005
+ { $unwind: { path: "$connection", preserveNullAndEmptyArrays: true } },
7006
+ { $project: { _id: 0, billingStatus: "$status", connection: 1, id: 1, name: "$title", subscription: 1 } },
7007
+ { $sort: { name: 1 } }
7008
+ ]
7009
+ });
7010
+ var storePlan = async ({ record, shopify }) => {
7011
+ var _a;
7012
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7013
+ if (!(settings == null ? void 0 : settings.accessToken)) return { active: false, name: null };
7014
+ try {
7015
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7016
+ adminAccessToken: settings.accessToken,
7017
+ domain: record.shop
7018
+ });
7019
+ const active = (subscriptions || []).length > 0;
7020
+ const metered = active && subscriptions.some((subscription) => subscription.metered === true) ? true : null;
7021
+ return { active, metered, name: active ? ((_a = subscriptions[0]) == null ? void 0 : _a.name) || null : null };
7022
+ } catch (_) {
7023
+ return { active: false, name: null, unavailable: true };
7024
+ }
7025
+ };
7026
+ var releaseApprovals = async ({ context, read, shopify }) => {
7027
+ var _a;
7028
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7029
+ if (!shop) throw refusal("shop is required", 400);
7030
+ const record = await read.get({ collection: "shop", query: { shop } });
7031
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7032
+ if (!(settings == null ? void 0 : settings.accessToken)) throw refusal("Store is not installed", 409);
7033
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7034
+ adminAccessToken: settings.accessToken,
7035
+ domain: shop
7036
+ });
7037
+ for (const subscription of subscriptions || []) {
7038
+ const data2 = await shopify.admin.adminFetch({
7039
+ adminAccessToken: settings.accessToken,
7040
+ domain: shop,
7041
+ query: "mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }",
7042
+ variables: { id: subscription.id }
7043
+ });
7044
+ const [userError] = ((_a = data2 == null ? void 0 : data2.appSubscriptionCancel) == null ? void 0 : _a.userErrors) || [];
7045
+ if (userError) throw refusal(userError.message, 422);
7046
+ }
7047
+ return {
7048
+ message: "Released " + (subscriptions || []).length + " approval(s).",
7049
+ request: { shop },
7050
+ result: { ok: true }
7051
+ };
7052
+ };
7053
+ var LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
7054
+ var meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
7055
+ var _a;
7056
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain: shop });
7057
+ let metered = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
7058
+ if (!((partner == null ? void 0 : partner.partnerToken) && (partner == null ? void 0 : partner.partnerOrgId) && (partner == null ? void 0 : partner.partnerAppId) && shopId)) {
7059
+ return { boundaries: [], ledger: false, metered };
7060
+ }
7061
+ const accruals = await shopify.partner.getUsageChargeEvents({
7062
+ appId: "gid://shopify/App/" + partner.partnerAppId,
7063
+ first: 10,
7064
+ occurredAtMin: ledgerFloor.toISOString(),
7065
+ organizationId: partner.partnerOrgId,
7066
+ partnerToken: partner.partnerToken,
7067
+ shopId: "gid://shopify/Shop/" + shopId
7068
+ });
7069
+ const boundaries = (accruals || []).filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
7070
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
7071
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
7072
+ return { boundaries, ledger: true, metered };
7073
+ };
7074
+ var toVariant = (edge) => {
7075
+ var _a, _b, _c, _d;
7076
+ return {
7077
+ availableForSale: edge.node.availableForSale || false,
7078
+ currency: ((_a = edge.node.price) == null ? void 0 : _a.currencyCode) || null,
7079
+ id: edge.node.id,
7080
+ image: ((_b = edge.node.image) == null ? void 0 : _b.url) || null,
7081
+ price: parseFloat((_c = edge.node.price) == null ? void 0 : _c.amount) || null,
7082
+ requiresShipping: edge.node.requiresShipping || false,
7083
+ sale: parseFloat((_d = edge.node.compareAtPrice) == null ? void 0 : _d.amount) || null,
7084
+ title: edge.node.title !== "Default Title" ? edge.node.title : null
7085
+ };
7086
+ };
7087
+ var applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
7088
+ try {
7089
+ const outOfStock = new Set(await shopify.storefront.probeVariantsOutOfStock({
7090
+ domain,
7091
+ storefrontAccessToken,
7092
+ variantIds: variants.map((variant) => variant.id)
7093
+ }));
7094
+ return variants.map((variant) => ({
7095
+ ...variant,
7096
+ availableForSale: variant.availableForSale && !outOfStock.has(variant.id)
7097
+ }));
7098
+ } catch (_) {
7099
+ return variants;
7100
+ }
7101
+ };
7102
+ var sendFeedback = async ({ adminToken, connection: connection2, messages, productId, shopify, state }) => {
7103
+ try {
7104
+ await shopify.admin.sendProductResourceFeedback({
7105
+ adminAccessToken: await adminToken(),
7106
+ domain: connection2.shop,
7107
+ messages,
7108
+ productId,
7109
+ state
7110
+ });
7111
+ return null;
7112
+ } catch (error) {
7113
+ return error;
7114
+ }
7115
+ };
7116
+ var resolveProduct = async ({ connection: connection2, context, read }) => {
7117
+ const byId = (context == null ? void 0 : context.product) ? await read.get({ collection: "product", query: { id: context.product } }) : null;
7118
+ const row2 = byId || ((context == null ? void 0 : context.providerId) && ((context == null ? void 0 : context.shop) || (connection2 == null ? void 0 : connection2.shop)) ? await read.get({
7119
+ collection: "product",
7120
+ query: {
7121
+ "provider.id": context.providerId,
7122
+ "provider.slug": "shopify",
7123
+ "source.domain": context.shop || connection2.shop
7124
+ }
7125
+ }) : null);
7126
+ return row2;
7127
+ };
7128
+ var UNAVAILABLE_FEEDBACK = "Drawbridge can't display this product. Check it is active and available in the United States.";
7129
+ var NOT_FOUND = "Shopify product not found";
7130
+ var syncProduct = async ({ adminToken, connection: connection2, context, read, settings, shopify }) => {
7131
+ var _a, _b, _c;
7132
+ const row2 = await resolveProduct({ connection: connection2, context, read });
7133
+ if (!row2) return { message: "No product row to sync.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7134
+ const domain = connection2.shop;
7135
+ const providerId = (_a = row2.provider) == null ? void 0 : _a.id;
7136
+ const request2 = { product: row2.id, providerId: providerId || null, shop: domain };
7137
+ let product;
7138
+ try {
7139
+ product = await shopify.storefront.getProduct({
7140
+ domain,
7141
+ productId: providerId,
7142
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7143
+ });
7144
+ } catch (error) {
7145
+ if (error.message !== NOT_FOUND) throw error;
7146
+ const failed2 = await sendFeedback({
7147
+ adminToken,
7148
+ connection: connection2,
7149
+ messages: [UNAVAILABLE_FEEDBACK],
7150
+ productId: providerId,
7151
+ shopify,
7152
+ state: "REQUIRES_ACTION"
7153
+ });
7154
+ return {
7155
+ failed: failed2 ? [failed2.message] : void 0,
7156
+ message: "Storefront cannot see this product \u2014 deactivated.",
7157
+ request: request2,
7158
+ result: { status: "inactive" },
7159
+ writes: [
7160
+ {
7161
+ collection: "product",
7162
+ data: { $set: { status: "inactive" } },
7163
+ operation: "update",
7164
+ query: { id: row2.id }
7165
+ },
7166
+ {
7167
+ collection: "advertisement",
7168
+ data: { $set: { status: "drafted" } },
7169
+ multiple: true,
7170
+ operation: "update",
7171
+ query: { product: row2.id }
7172
+ }
7173
+ ]
7174
+ };
7175
+ }
7176
+ const variants = await applyOutOfStock({
7177
+ domain,
7178
+ shopify,
7179
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7180
+ variants: (((_b = product == null ? void 0 : product.variants) == null ? void 0 : _b.edges) || []).map(toVariant)
7181
+ });
7182
+ const failed = await sendFeedback({
7183
+ adminToken,
7184
+ connection: connection2,
7185
+ messages: [],
7186
+ productId: providerId,
7187
+ shopify,
7188
+ state: "ACCEPTED"
7189
+ });
7190
+ return {
7191
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
7192
+ // written above; a product with thousands of variants becomes N short
7193
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
7194
+ enqueues: [{
7195
+ data: { operation: "variants", product: row2.id },
7196
+ name: "sync",
7197
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7198
+ queue: "product.shopify.variants"
7199
+ }],
7200
+ failed: failed ? [failed.message] : void 0,
7201
+ message: "Synced " + (product.title || "product") + ".",
7202
+ request: request2,
7203
+ result: { status: "active", variants: variants.length },
7204
+ writes: [{
7205
+ collection: "product",
7206
+ data: {
7207
+ $set: {
7208
+ description: product.description || null,
7209
+ image: ((_c = product.featuredImage) == null ? void 0 : _c.url) || null,
7210
+ // A successful sync PROVES channel visibility, which reverses the
7211
+ // deactivation above. Ads stay drafted.
7212
+ status: "active",
7213
+ title: product.title || null,
7214
+ type: product.productType || null,
7215
+ url: domain && product.handle ? "https://" + domain + "/products/" + product.handle : null,
7216
+ variants
7217
+ }
7218
+ },
7219
+ operation: "update",
7220
+ query: { id: row2.id }
7221
+ }]
7222
+ };
7223
+ };
7224
+ var syncVariants = async ({ connection: connection2, context, read, settings, shopify }) => {
7225
+ var _a, _b, _c;
7226
+ const row2 = await resolveProduct({ connection: connection2, context, read });
7227
+ if (!row2) return { message: "No product row to paginate.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7228
+ const cursor = (context == null ? void 0 : context.cursor) || null;
7229
+ const request2 = { cursor, product: row2.id };
7230
+ let page2;
7231
+ try {
7232
+ page2 = await shopify.storefront.getProductVariantsPage({
7233
+ cursor,
7234
+ domain: connection2.shop,
7235
+ productId: (_a = row2.provider) == null ? void 0 : _a.id,
7236
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7237
+ });
7238
+ } catch (error) {
7239
+ if (error.message !== NOT_FOUND) throw error;
7240
+ return { message: "Storefront lost this product mid-pagination \u2014 stopping.", request: request2, skipped: true };
7241
+ }
7242
+ const variants = await applyOutOfStock({
7243
+ domain: connection2.shop,
7244
+ shopify,
7245
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7246
+ variants: (page2.edges || []).map(toVariant)
7247
+ });
7248
+ return {
7249
+ ...((_b = page2.pageInfo) == null ? void 0 : _b.hasNextPage) && { enqueues: [{
7250
+ data: { cursor: page2.pageInfo.endCursor, operation: "variants", product: row2.id },
7251
+ name: "page",
7252
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7253
+ queue: "product.shopify.variants"
7254
+ }] },
7255
+ message: variants.length + " variant(s) on this page.",
7256
+ request: request2,
7257
+ result: { hasNextPage: Boolean((_c = page2.pageInfo) == null ? void 0 : _c.hasNextPage), variants: variants.length },
7258
+ writes: [{
7259
+ collection: "product",
7260
+ data: cursor ? { $push: { variants: { $each: variants } } } : { $set: { variants } },
7261
+ operation: "update",
7262
+ query: { id: row2.id }
7263
+ }]
7264
+ };
7265
+ };
7266
+ var shopify_default2 = {
7267
+ // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
7268
+ // a BUTTON LABEL inside the copy object next to a url — the title and the
7269
+ // address it belongs to sat at different levels and could drift apart.
7270
+ //
7271
+ // It names where the link GOES rather than what it does: installing happens on
7272
+ // the App Store listing, and the dashboard must never imply a store can be
7273
+ // linked from inside it.
7274
+ actions: {
7275
+ create: null,
7276
+ listing: "View on the Shopify App Store",
7277
+ manage: null,
7278
+ update: null
7279
+ },
7280
+ // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
7281
+ // sees a consent screen we sent them to -- they start at the App Store, and
7282
+ // the install completes inside Shopify admin without redirecting back. A
7283
+ // Connect button here would be lying about where connecting happens.
7284
+ auth: {
7285
+ // ONE STORE PER ORGANIZATION. A Shopify install IS a store, and a second
7286
+ // one would give an organization two catalogues, two order streams and two
7287
+ // usage meters with nothing to say which is authoritative.
7288
+ //
7289
+ // DECLARED AND ENFORCED BY NOTHING, measured 2026-09-13: no repo reads this
7290
+ // key, and the connection collection's { organization, slug } index is not
5236
7291
  // unique. So a second Shopify connection would be accepted today. The gate
5237
7292
  // belongs at the connect route with a unique partial index behind it, and
5238
7293
  // that is api work — this is the declaration it will read.
@@ -5394,8 +7449,8 @@ var shopify_default2 = {
5394
7449
  // string; this answers what is missing from it.
5395
7450
  //
5396
7451
  // `shopify` is injected for the same reason it is everywhere else — this
5397
- // package cannot import @drawbridge/shopify, which depends on it.
5398
- scopes: ({ scope }, { shopify } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
7452
+ // client is imported here and defaulted, never wired in by a caller.
7453
+ scopes: ({ scope }, { shopify = client } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
5399
7454
  // Shopify's install grant is exchanged inside its own app flow, not
5400
7455
  // through the shared OAuth runner.
5401
7456
  token: false
@@ -5409,17 +7464,53 @@ var shopify_default2 = {
5409
7464
  // can reference another), `dispatch` (the caller's own coordinator table,
5410
7465
  // for the hooks that are dispatches).
5411
7466
  commerce: {
5412
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
5413
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
5414
- // exactly why it never became a hook: a hook is reached to have its effects
5415
- // performed, and this one has none to perform. It moves here when the vendor
5416
- // client moves into the manifest and `subdomain.js` stops importing the
5417
- // package directly.
5418
- cart: false,
7467
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
7468
+ // the storefront client from api/route/subdomain.js.
7469
+ //
7470
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
7471
+ // become a hook a hook is usually reached to have its effects performed
7472
+ // and this one has none. That reasoning was wrong: a hook is also the one
7473
+ // place a vendor's client is allowed to be called from, and leaving the
7474
+ // cart out meant a public route importing a vendor SDK directly.
7475
+ //
7476
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
7477
+ // uses for its topic table. Six slots would be six names in the closed
7478
+ // vocabulary for one question — what does this shopper's cart look like
7479
+ // now — asked six ways.
7480
+ //
7481
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
7482
+ // mutations answer "that variant is sold out" as a userError rather than a
7483
+ // transport failure, and the route it replaced already told the two apart.
7484
+ // runHook carries `status` off a thrown error, so it survives to the
7485
+ // caller; the rest of a thrown error's shape does not.
7486
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {}) => {
7487
+ const { cartId, lineIds, lines, operation } = context || {};
7488
+ const { domain, storefrontAccessToken } = settings || {};
7489
+ if (operation === "checkout") {
7490
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
7491
+ return { message: "Checkout token minted.", result: { token } };
7492
+ }
7493
+ const storefront = { domain, fetcher, storefrontAccessToken };
7494
+ try {
7495
+ const cart = await ({
7496
+ create: () => shopify.storefront.cartCreate({ ...storefront, lines }),
7497
+ get: () => shopify.storefront.cartGet({ ...storefront, cartId }),
7498
+ linesAdd: () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
7499
+ linesRemove: () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
7500
+ linesUpdate: () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
7501
+ }[operation] || (() => {
7502
+ throw Object.assign(new Error("Unknown cart operation: " + operation), { status: 400 });
7503
+ }))();
7504
+ return { message: "Cart " + operation + ".", request: { cartId: cartId || null, operation }, result: cart };
7505
+ } catch (error) {
7506
+ if (error == null ? void 0 : error.userError) throw Object.assign(error, { status: 400 });
7507
+ throw error;
7508
+ }
7509
+ },
5419
7510
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
5420
7511
  // one lead — which is what lets an order that redeems it be attributed
5421
7512
  // back.
5422
- code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
7513
+ code: async ({ connection: connection2, context, step }, { adminToken, shopify = client } = {}) => {
5423
7514
  var _a;
5424
7515
  const discount = (_a = step.settings) == null ? void 0 : _a.discount;
5425
7516
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
@@ -5465,7 +7556,7 @@ var shopify_default2 = {
5465
7556
  // customer at the store is a support ticket: the context may already
5466
7557
  // carry the id from an earlier step, the lead may already be linked from
5467
7558
  // an earlier run, and Shopify's own get-or-create settles the rest.
5468
- customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
7559
+ customer: async ({ connection: connection2, context }, { adminToken, read, shopify = client } = {}) => {
5469
7560
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
5470
7561
  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 };
5471
7562
  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 };
@@ -5515,6 +7606,30 @@ var shopify_default2 = {
5515
7606
  }]
5516
7607
  };
5517
7608
  },
7609
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
7610
+ // rendered rather than stored on the product, because stock is the one
7611
+ // product fact that is stale the instant it is written down.
7612
+ //
7613
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
7614
+ // caller wanted the total; the one that existed summed it inline, and
7615
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
7616
+ // bookkeeping key got counted as a quantity.
7617
+ inventory: async ({ connection: connection2, context }, { adminToken, shopify = client } = {}) => {
7618
+ const productId = context == null ? void 0 : context.product;
7619
+ const request2 = { product: productId || null, shop: connection2.shop };
7620
+ if (!productId) return { message: "No product id \u2014 nothing to count.", request: request2, result: { total: null }, skipped: true };
7621
+ const adminAccessToken = await adminToken();
7622
+ const byVariant = await shopify.admin.getProductInventory({
7623
+ adminAccessToken,
7624
+ domain: connection2.shop,
7625
+ productId
7626
+ });
7627
+ return {
7628
+ message: "Inventory read.",
7629
+ request: request2,
7630
+ result: { total: Object.values(byVariant || {}).reduce((sum, quantity) => sum + quantity, 0) }
7631
+ };
7632
+ },
5518
7633
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
5519
7634
  // attribution genuinely is: an order can reach Drawbridge two ways and
5520
7635
  // they bill differently.
@@ -5835,6 +7950,12 @@ var shopify_default2 = {
5835
7950
  orderId: String(orderId),
5836
7951
  rate,
5837
7952
  shopId: connection2.source.id,
7953
+ // WHICH VENDOR IS BEING CHARGED, so the sender does not have
7954
+ // to assume. queue/usage.js named the slug itself and
7955
+ // defaults to it for anything already queued when this
7956
+ // shipped — an app-store vendor billing through its own meter
7957
+ // enqueues the same job with its own slug.
7958
+ slug: connection2.slug,
5838
7959
  // The App Events API returns no event id, so one is generated
5839
7960
  // here — the event handle plus the order id — and sent as the
5840
7961
  // event's `reference`. queue/usage.js stamps the same id onto
@@ -5874,10 +7995,28 @@ var shopify_default2 = {
5874
7995
  //
5875
7996
  // The shell has already refused a missing or inactive Shopify connection,
5876
7997
  // so what is left is the two things only this hook can know are wrong.
5877
- product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
7998
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
7999
+ // slots in the closed vocabulary, the same shape inbound.process uses
8000
+ // for its topic table — they are one subject, and a vendor that syncs
8001
+ // products has to answer all three or none.
8002
+ //
8003
+ // record a webhook said a product changed: upsert the row and
8004
+ // queue the pull
8005
+ // sync pull the product from the storefront and write what it
8006
+ // says, including what it says by NOT answering
8007
+ // variants one page of variants, self-chaining
8008
+ //
8009
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
8010
+ // queue/product.js — a shadow implementation of the step this manifest
8011
+ // already declared, importing three of the vendor's client modules from
8012
+ // a worker file.
8013
+ product: async ({ connection: connection2, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {}) => {
8014
+ const operation = (context == null ? void 0 : context.operation) || "record";
8015
+ if (operation === "sync") return syncProduct({ adminToken, connection: connection2, context, read, settings, shopify });
8016
+ if (operation === "variants") return syncVariants({ connection: connection2, context, read, settings, shopify });
5878
8017
  const request2 = {
5879
8018
  numericId: (context == null ? void 0 : context.id) || null,
5880
- organizationId: workflow.organization,
8019
+ organizationId: (workflow == null ? void 0 : workflow.organization) || null,
5881
8020
  title: (context == null ? void 0 : context.title) || null
5882
8021
  };
5883
8022
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
@@ -5927,9 +8066,612 @@ var shopify_default2 = {
5927
8066
  }
5928
8067
  },
5929
8068
  contacts: { remove: false, sync: false },
5930
- // The embedded-app surface. Only a vendor whose merchants arrive from its
5931
- // own app store has one.
5932
- install: false,
8069
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
8070
+ // own app store has one, and these thirteen slots are the thirteen routes
8071
+ // that used to live in api/route/shopify-embedded.js.
8072
+ //
8073
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
8074
+ // holds a controller — `read` is the controller's read methods and nothing
8075
+ // else. Where one REFUSES it throws with a status, and the route answers
8076
+ // what it said. The helpers they share are above the manifest.
8077
+ install: {
8078
+ account: {
8079
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
8080
+ // authenticates the merchant once, top-level, and stashes their user
8081
+ // token in `install.token.save`; App Bridge has already proven shop
8082
+ // control by the time this runs.
8083
+ //
8084
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
8085
+ // token: the presented one must BE this shop's one-shot handoff token,
8086
+ // unexpired, and an access or pat record — a refresh or oauth-state
8087
+ // token that happens to resolve to a user must not qualify, which is
8088
+ // the rule a session is held to in drawbridge-api's middleware.
8089
+ link: async ({ context }, { read } = {}) => {
8090
+ var _a;
8091
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8092
+ const token = context == null ? void 0 : context.token;
8093
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8094
+ const now = /* @__PURE__ */ new Date();
8095
+ const record = await read.get({ collection: "shop", query: { shop } });
8096
+ const stashed = (record == null ? void 0 : record.linkToken) ? (_a = decrypt(record.linkToken)) == null ? void 0 : _a.token : null;
8097
+ const expired = !(record == null ? void 0 : record.linkTokenExpiresAt) || new Date(record.linkTokenExpiresAt) <= now;
8098
+ if (!stashed || expired || stashed !== token) throw refusal("Invalid or expired link token", 401);
8099
+ const granted = await read.get({
8100
+ collection: "token",
8101
+ query: {
8102
+ revoked: false,
8103
+ tokenHash: hashToken(token),
8104
+ type: { $in: [tokenTypes.access, tokenTypes.pat] }
8105
+ }
8106
+ });
8107
+ if (!(granted == null ? void 0 : granted.user) || granted.expiresAt && new Date(granted.expiresAt) <= now) {
8108
+ throw refusal("Invalid or expired link token", 401);
8109
+ }
8110
+ const switching = Boolean(record == null ? void 0 : record.user) && record.user !== granted.user;
8111
+ const connections2 = switching ? await read.aggregate({
8112
+ collection: "connection",
8113
+ pipeline: [
8114
+ { $match: { shop, slug: "shopify" } },
8115
+ { $project: { _id: 0, id: 1 } }
8116
+ ]
8117
+ }) : [];
8118
+ return {
8119
+ message: switching ? "Linked to a different Drawbridge account." : "Linked.",
8120
+ request: { shop },
8121
+ result: { connected: true, switched: switching },
8122
+ // ALL OR NONE. A half-done switch is a store linked to the new
8123
+ // account while the old account's connections still serve from it.
8124
+ transaction: true,
8125
+ writes: [
8126
+ ...(connections2 || []).map((connection2) => ({
8127
+ collection: "connection",
8128
+ operation: "delete",
8129
+ query: { id: connection2.id }
8130
+ })),
8131
+ {
8132
+ collection: "shop",
8133
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: granted.user } },
8134
+ operation: "update",
8135
+ query: { shop }
8136
+ }
8137
+ ]
8138
+ };
8139
+ },
8140
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
8141
+ // the durable link clears; the offline token and the shop record stay,
8142
+ // because the app is still installed and the merchant may link a
8143
+ // different account next.
8144
+ unlink: async ({ context }, { read } = {}) => {
8145
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8146
+ if (!shop) throw refusal("shop is required", 400);
8147
+ const connections2 = await read.aggregate({
8148
+ collection: "connection",
8149
+ pipeline: [
8150
+ { $match: { shop, slug: "shopify" } },
8151
+ { $project: { _id: 0, id: 1 } }
8152
+ ]
8153
+ });
8154
+ return {
8155
+ message: "Unlinked " + (connections2 || []).length + " connection(s).",
8156
+ request: { shop },
8157
+ result: { ok: true },
8158
+ transaction: true,
8159
+ writes: [
8160
+ ...(connections2 || []).map((connection2) => ({
8161
+ collection: "connection",
8162
+ operation: "delete",
8163
+ query: { id: connection2.id }
8164
+ })),
8165
+ {
8166
+ collection: "shop",
8167
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: null } },
8168
+ operation: "update",
8169
+ query: { shop }
8170
+ }
8171
+ ]
8172
+ };
8173
+ }
8174
+ },
8175
+ organizations: {
8176
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
8177
+ // gate in it came from a line in the route it replaces.
8178
+ add: async ({ context }, { currencies: currencies2, mintId, read, shopify = client } = {}) => {
8179
+ var _a, _b;
8180
+ const organization2 = context == null ? void 0 : context.organization;
8181
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8182
+ if (!shop) throw refusal("shop is required", 400);
8183
+ if (!organization2) throw refusal("organization is required", 400);
8184
+ const record = await read.get({ collection: "shop", query: { shop } });
8185
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8186
+ const owned = await merchantOrganizations({ read, user: record.user });
8187
+ const org = (owned || []).find((entry) => entry.id === organization2);
8188
+ if (!org) throw refusal("No access to this organization", 403);
8189
+ if (!CONNECTABLE_STATUSES.includes(org.billingStatus)) {
8190
+ throw refusal("This organization can't accept connections right now \u2014 check its billing status in the dashboard", 403);
8191
+ }
8192
+ if (org.connection && org.connection.shop !== shop) {
8193
+ throw refusal("This organization is already connected to another Shopify store", 409);
8194
+ }
8195
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
8196
+ if (!(settings == null ? void 0 : settings.accessToken)) {
8197
+ throw refusal("Store is not installed \u2014 open the app in the Shopify admin first", 409);
8198
+ }
8199
+ const plan = await storePlan({ record, shopify });
8200
+ if (plan.unavailable) throw refusal("Couldn't verify the store's plan \u2014 try again shortly", 502);
8201
+ if (!plan.active) throw refusal("Approve a plan before connecting organizations", 402);
8202
+ let storefront = null;
8203
+ if (!record.storefront) {
8204
+ try {
8205
+ const minted2 = await shopify.oauth.createStorefrontToken({
8206
+ adminAccessToken: settings.accessToken,
8207
+ shop
8208
+ });
8209
+ if (minted2) storefront = encrypt({ token: minted2 });
8210
+ } catch (_) {
8211
+ }
8212
+ }
8213
+ let currency = (record == null ? void 0 : record.currency) || null;
8214
+ let source = (record == null ? void 0 : record.source) || null;
8215
+ try {
8216
+ const shopData = await shopify.oauth.getShop({ adminAccessToken: settings.accessToken, shop });
8217
+ if (shopData) {
8218
+ currency = String(shopData.currency || "").toLowerCase() || null;
8219
+ source = { domain: shopData.myshopifyDomain, id: String(shopData.id), label: shopData.name };
8220
+ }
8221
+ } catch (_) {
8222
+ }
8223
+ if (currency && currencies2 && !currencies2.includes(currency)) {
8224
+ throw refusal("This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.", 422);
8225
+ }
8226
+ const minted = ((_a = org.connection) == null ? void 0 : _a.id) ? null : mintId();
8227
+ const connectionId = ((_b = org.connection) == null ? void 0 : _b.id) || minted.id;
8228
+ return {
8229
+ message: "Connected " + org.name + " to " + shop + ".",
8230
+ request: { organization: organization2, shop },
8231
+ result: { ok: true },
8232
+ transaction: true,
8233
+ writes: [
8234
+ ...storefront ? [{
8235
+ collection: "shop",
8236
+ data: { $set: { storefront } },
8237
+ operation: "update",
8238
+ query: { shop }
8239
+ }] : [],
8240
+ {
8241
+ collection: "connection",
8242
+ data: {
8243
+ $set: {
8244
+ currency,
8245
+ errors: [],
8246
+ // The token of record lives on the shared `shop` row; this
8247
+ // is the pointer that satisfies the required `settings`
8248
+ // string, and `auth.install.shared` declares how to read it.
8249
+ settings: encrypt({ ref: "shop", shop }),
8250
+ shop,
8251
+ status: "pending",
8252
+ ...source && { source }
8253
+ },
8254
+ $setOnInsert: {
8255
+ ...minted && { _id: minted._id, id: minted.id },
8256
+ feature: "organization:connection:shopify",
8257
+ organization: organization2,
8258
+ slug: "shopify"
8259
+ }
8260
+ },
8261
+ operation: "update",
8262
+ options: { upsert: true },
8263
+ query: { organization: organization2, slug: "shopify" }
8264
+ },
8265
+ {
8266
+ collection: "organization",
8267
+ data: {
8268
+ // $addToSet UNCONDITIONALLY. The route this replaces only
8269
+ // registered the refs when the upsert INSERTED, which meant an
8270
+ // org whose refs had drifted stayed drifted through every
8271
+ // re-link. Adding a member that is already there is a no-op,
8272
+ // so doing it every time costs nothing and repairs the drift.
8273
+ $addToSet: {
8274
+ "connections.groups": "ecommerce",
8275
+ "connections.ids": connectionId,
8276
+ "connections.keys": "shopify"
8277
+ },
8278
+ $set: { billingProvider: "shopify" }
8279
+ },
8280
+ operation: "update",
8281
+ query: { id: organization2 }
8282
+ }
8283
+ ]
8284
+ };
8285
+ },
8286
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
8287
+ // store's live plan, and the products the merchant has to act on.
8288
+ //
8289
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
8290
+ // `add` would refuse it — a list that offers what the next call rejects
8291
+ // is worse than a shorter one.
8292
+ list: async ({ context }, { conversionRate: conversionRate2, planTitle, read, shopify = client } = {}) => {
8293
+ var _a;
8294
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8295
+ if (!shop) throw refusal("shop is required", 400);
8296
+ const record = await read.get({ collection: "shop", query: { shop } });
8297
+ if (!(record == null ? void 0 : record.user)) {
8298
+ return {
8299
+ message: "No account linked to this store.",
8300
+ request: { shop },
8301
+ result: {
8302
+ account: null,
8303
+ connected: false,
8304
+ organizations: [],
8305
+ plan: { active: false, name: null },
8306
+ unavailableProducts: [],
8307
+ unavailableProductsTotal: 0
8308
+ }
8309
+ };
8310
+ }
8311
+ const owned = await merchantOrganizations({ read, user: record.user });
8312
+ const organizations = (owned || []).filter((org) => !org.connection || org.connection.shop === shop).map((org) => {
8313
+ var _a2;
8314
+ return {
8315
+ // The org's OWN billing status decides whether `add` accepts it.
8316
+ // Sent up so the app can disable Connect and name the reason
8317
+ // rather than letting the merchant click into a 403 —
8318
+ // listed-but-blocked beats hidden, because an org vanishing from
8319
+ // the list is indistinguishable from a bug.
8320
+ billingStatus: org.billingStatus || null,
8321
+ connectable: CONNECTABLE_STATUSES.includes(org.billingStatus),
8322
+ connected: Boolean(org.connection),
8323
+ conversion: conversionRate2(org.subscription),
8324
+ id: org.id,
8325
+ name: org.name,
8326
+ plan: planTitle(org.subscription),
8327
+ status: ((_a2 = org.connection) == null ? void 0 : _a2.status) || null
8328
+ };
8329
+ });
8330
+ const connections2 = (owned || []).filter((org) => {
8331
+ var _a2;
8332
+ return ((_a2 = org.connection) == null ? void 0 : _a2.shop) === shop && org.connection.id;
8333
+ }).map((org) => org.connection.id);
8334
+ const [account, plan, unavailable] = await Promise.all([
8335
+ read.get({ collection: "user", query: { id: record.user } }),
8336
+ storePlan({ record, shopify }),
8337
+ connections2.length ? read.aggregate({
8338
+ collection: "product",
8339
+ pipeline: [
8340
+ { $match: { connections: { $in: connections2 }, "source.domain": shop, status: "inactive" } },
8341
+ // $facet so the app can name the first 25 AND report a
8342
+ // truthful total: listing 25 of 40 under "25 products need
8343
+ // attention" would understate the problem.
8344
+ {
8345
+ $facet: {
8346
+ items: [{ $sort: { title: 1 } }, { $limit: 25 }, { $project: { _id: 0, id: 1, title: 1 } }],
8347
+ total: [{ $count: "count" }]
8348
+ }
8349
+ }
8350
+ ]
8351
+ }) : []
8352
+ ]);
8353
+ const facet = (unavailable || [])[0] || {};
8354
+ if (plan.active && plan.metered !== true) {
8355
+ const stamped = await read.aggregate({
8356
+ collection: "connection",
8357
+ pipeline: [
8358
+ { $match: { shop, slug: "shopify" } },
8359
+ { $project: { _id: 0, "source.metered": 1 } }
8360
+ ]
8361
+ });
8362
+ const verdicts = (stamped || []).map((row2) => {
8363
+ var _a2;
8364
+ return (_a2 = row2 == null ? void 0 : row2.source) == null ? void 0 : _a2.metered;
8365
+ });
8366
+ if (verdicts.some((verdict) => typeof verdict === "string")) plan.metered = true;
8367
+ else if (verdicts.some((verdict) => verdict === null)) plan.metered = false;
8368
+ }
8369
+ return {
8370
+ message: organizations.length + " organization(s) for this store.",
8371
+ request: { shop },
8372
+ result: {
8373
+ account: { email: (account == null ? void 0 : account.email) || null },
8374
+ connected: true,
8375
+ organizations,
8376
+ plan,
8377
+ unavailableProducts: (facet.items || []).map((product) => ({
8378
+ id: product.id,
8379
+ title: product.title || "Untitled product"
8380
+ })),
8381
+ // The total can exceed the 25 listed above, and has to.
8382
+ unavailableProductsTotal: ((_a = (facet.total || [])[0]) == null ? void 0 : _a.count) || 0
8383
+ }
8384
+ };
8385
+ },
8386
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
8387
+ // else: every dependent — products, ads, steps, workflows, and the org's
8388
+ // own connection refs — is torn down by drawbridge-sync's connection
8389
+ // change stream. The shared token and the other orgs are untouched.
8390
+ remove: async ({ context }, { read } = {}) => {
8391
+ const organization2 = context == null ? void 0 : context.organization;
8392
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8393
+ if (!shop) throw refusal("shop is required", 400);
8394
+ if (!organization2) throw refusal("organization is required", 400);
8395
+ const record = await read.get({ collection: "shop", query: { shop } });
8396
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8397
+ const owned = await merchantOrganizations({ read, user: record.user });
8398
+ if (!(owned || []).some((entry) => entry.id === organization2)) {
8399
+ throw refusal("No access to this organization", 403);
8400
+ }
8401
+ const connection2 = await read.get({
8402
+ collection: "connection",
8403
+ query: { organization: organization2, shop, slug: "shopify" }
8404
+ });
8405
+ if (!connection2) throw refusal("No connection to remove", 404);
8406
+ return {
8407
+ message: "Disconnected.",
8408
+ request: { organization: organization2, shop },
8409
+ result: { ok: true },
8410
+ writes: [{ collection: "connection", operation: "delete", query: { id: connection2.id } }]
8411
+ };
8412
+ }
8413
+ },
8414
+ plan: {
8415
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
8416
+ // and falls back to "Choose a plan".
8417
+ cancel: ({ context }, { read, shopify = client } = {}) => releaseApprovals({ context, read, shopify }),
8418
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
8419
+ // order step and run on a retrying queue, so this may be called
8420
+ // several times for one order — which is safe because the idempotency
8421
+ // key is PERMANENT for billing events and Shopify settles the repeat.
8422
+ //
8423
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
8424
+ // is a real strengthening of what it replaced: drawbridge-sync compared
8425
+ // the job's `handle` against the job's `transaction` — two fields from
8426
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
8427
+ // handle is the one string that decides whether the event bills at all,
8428
+ // and this is the file that publishes it.
8429
+ //
8430
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
8431
+ // idempotency key on an event that misclassifies or mistraces. The
8432
+ // throw rides the queue's normal retry path and the abandoned-job alert
8433
+ // pages on exhaustion.
8434
+ //
8435
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
8436
+ // which is always inside the merchant's current billing cycle at the
8437
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
8438
+ // purchase time reads more correct and is strictly worse: a retry
8439
+ // backoff crossing a cycle boundary would carry a closed-cycle
8440
+ // timestamp, Shopify would 202-accept, silently drop it, and the
8441
+ // commission would be lost. Send-time's worst case is benign — the
8442
+ // charge lands in the next cycle, but it lands.
8443
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify = client } = {}) => {
8444
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
8445
+ const handle = (context == null ? void 0 : context.handle) || manifest.auth.install.billing.handle;
8446
+ if (!transaction || !String(transaction).startsWith(handle + ".")) {
8447
+ throw new Error("shopify.usage.billing.handle.mismatch on order " + orderId + ": transaction " + (transaction || "null") + " does not carry handle " + handle + " \u2014 refusing to send");
8448
+ }
8449
+ await shopify.billing.sendAppEvent({
8450
+ clientId,
8451
+ clientSecret,
8452
+ eventHandle: handle,
8453
+ idempotencyKey,
8454
+ // The Shopify-side event record names the order that produced it.
8455
+ reference: transaction,
8456
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
8457
+ // send. `revision` suffixes the idempotency key, which is what
8458
+ // makes a second attempt possible once a key has been consumed;
8459
+ // the encoding is owned by sendAppEvent so no caller builds keys
8460
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
8461
+ // path must never do — see the note above — and the recovery
8462
+ // migration must, because it is resending orders from a closed
8463
+ // window and bounds its own run to the merchant's current cycle.
8464
+ ...revision && { revision },
8465
+ ...timestamp && { timestamp },
8466
+ shopId,
8467
+ value
8468
+ });
8469
+ return {
8470
+ message: "Usage charge sent for order " + orderId + ".",
8471
+ request: { order: order || null, orderId: orderId || null, shopId: shopId || null, value },
8472
+ result: { transaction: String(transaction) },
8473
+ // TRACEABILITY. The App Events API returns no event id, so the
8474
+ // trace key into Shopify's event logs is the transaction id the
8475
+ // commerce.order hook minted and this sent as `reference`.
8476
+ // `amount` is the per-order usage value in cents, mirroring the
8477
+ // Stripe path's marker.
8478
+ ...order && { writes: [{
8479
+ collection: "order",
8480
+ data: {
8481
+ $set: {
8482
+ billed: {
8483
+ amount: value,
8484
+ date: /* @__PURE__ */ new Date(),
8485
+ rate: rate || 0,
8486
+ transaction: String(transaction)
8487
+ }
8488
+ }
8489
+ },
8490
+ operation: "update",
8491
+ query: { id: order }
8492
+ }] }
8493
+ };
8494
+ },
8495
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
8496
+ // be released before the pricing page will offer the plan again — but
8497
+ // the intent is a thirty-second round trip rather than a departure.
8498
+ //
8499
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
8500
+ // needs one. The marker has to be persisted BEFORE the approval is
8501
+ // released: it is what tells sync's CANCELLED handler to hold the pause
8502
+ // — no connection error, no owner email — and start a timed recheck that
8503
+ // pauses honestly if the merchant abandons. A described write lands
8504
+ // after the hook returns, which is after the cancel, and a marker
8505
+ // written then can race the very webhook it exists to soften. Same
8506
+ // precedent as `resolveContact`: the one write a description cannot
8507
+ // carry.
8508
+ reselect: async ({ context }, { mark, read, shopify = client } = {}) => {
8509
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8510
+ if (!shop) throw refusal("shop is required", 400);
8511
+ await mark({ shop });
8512
+ return releaseApprovals({ context, read, shopify });
8513
+ },
8514
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
8515
+ // action — the other two slots here are — but the same subject, and
8516
+ // the reason it is a declared hook rather than a helper is that the
8517
+ // answer was being derived in two places: `lifecycle.health` below,
8518
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
8519
+ // stated in its own comment that it matched this one and did not.
8520
+ //
8521
+ // THREE ANSWERS, and the caller must keep them apart:
8522
+ //
8523
+ // a string PROVEN. The charge id or usage line id, stamped on
8524
+ // the connection as the evidence.
8525
+ // null PROBED, and nothing is accruing.
8526
+ // undefined NO VERDICT — the probe could not run. `probed` says
8527
+ // which, because undefined does not survive JSON.
8528
+ verify: async ({ context }, { adminToken, read, shopify = client } = {}) => {
8529
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8530
+ if (!shop) throw refusal("shop is required", 400);
8531
+ const adminAccessToken = await adminToken();
8532
+ if (!adminAccessToken) {
8533
+ return { message: "No admin token for this store \u2014 no verdict.", request: { shop }, result: { metered: null, probed: false }, skipped: true };
8534
+ }
8535
+ const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
8536
+ const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
8537
+ const { ledger, metered } = await meterVerdict({
8538
+ adminAccessToken,
8539
+ ledgerFloor: new Date(Date.now() - LEDGER_WINDOW_MS),
8540
+ partner,
8541
+ shop,
8542
+ shopId: (context == null ? void 0 : context.shopId) || null,
8543
+ shopify
8544
+ });
8545
+ return {
8546
+ message: typeof metered === "string" ? "Billing is metered." : "No meter evidence for this store.",
8547
+ request: { shop },
8548
+ // `probed` is the LEDGER, not the call: a store whose Partner
8549
+ // credentials are absent was not asked, and reporting that as
8550
+ // "probed, nothing accruing" would stamp an unmetered verdict on
8551
+ // a store nobody looked at.
8552
+ result: { metered: metered ?? null, probed: ledger || typeof metered === "string" }
8553
+ };
8554
+ }
8555
+ },
8556
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
8557
+ // persists the offline session on token exchange and refresh; it lives
8558
+ // encrypted on the shared `shop` record, which is the same row every
8559
+ // organization's connection to this store points at.
8560
+ session: {
8561
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
8562
+ // invalidation and not only on uninstall, so hard-deleting the record
8563
+ // would orphan every org connection pointing at it — token resolution
8564
+ // silently nulls — and destroy the durable account link and the
8565
+ // storefront token with it. The next embedded load re-runs token
8566
+ // exchange. The full record delete belongs to the uninstall webhook.
8567
+ clear: async ({ context }) => {
8568
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8569
+ if (!shop) throw refusal("shop is required", 400);
8570
+ return {
8571
+ message: "Session cleared.",
8572
+ request: { shop },
8573
+ result: { ok: true },
8574
+ writes: [{
8575
+ collection: "shop",
8576
+ data: { $set: { settings: null } },
8577
+ operation: "update",
8578
+ query: { shop }
8579
+ }]
8580
+ };
8581
+ },
8582
+ read: async ({ context }, { read } = {}) => {
8583
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8584
+ if (!shop) throw refusal("shop is required", 400);
8585
+ const record = await read.get({ collection: "shop", query: { shop } });
8586
+ if (!(record == null ? void 0 : record.settings)) throw refusal("No session for this store", 404);
8587
+ return {
8588
+ message: "Session read.",
8589
+ request: { shop },
8590
+ result: { session: decrypt(record.settings) }
8591
+ };
8592
+ },
8593
+ save: async ({ context }) => {
8594
+ const session = context == null ? void 0 : context.session;
8595
+ const shop = sanitizeDomain(session == null ? void 0 : session.shop);
8596
+ if (!shop || !(session == null ? void 0 : session.accessToken)) throw refusal("session.shop and session.accessToken are required", 400);
8597
+ return {
8598
+ message: "Session saved.",
8599
+ request: { shop },
8600
+ result: { ok: true },
8601
+ writes: [{
8602
+ collection: "shop",
8603
+ data: {
8604
+ $set: { settings: encrypt(session), shop },
8605
+ $setOnInsert: { status: "pending" }
8606
+ },
8607
+ operation: "update",
8608
+ options: { upsert: true },
8609
+ query: { shop }
8610
+ }]
8611
+ };
8612
+ }
8613
+ },
8614
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
8615
+ // a first-party cookie; the embedded App Home cannot read that cookie from
8616
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
8617
+ // user token here keyed by shop, and App Home reads it once and clears it.
8618
+ //
8619
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
8620
+ // mid-handoff. Short-lived and encrypted.
8621
+ token: {
8622
+ clear: async ({ context }) => {
8623
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8624
+ if (!shop) throw refusal("shop is required", 400);
8625
+ return {
8626
+ message: "Link token cleared.",
8627
+ request: { shop },
8628
+ result: { ok: true },
8629
+ writes: [{
8630
+ collection: "shop",
8631
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null } },
8632
+ operation: "update",
8633
+ query: { shop }
8634
+ }]
8635
+ };
8636
+ },
8637
+ read: async ({ context }, { read } = {}) => {
8638
+ var _a;
8639
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8640
+ if (!shop) throw refusal("shop is required", 400);
8641
+ const record = await read.get({ collection: "shop", query: { shop } });
8642
+ const expiresAt = (record == null ? void 0 : record.linkTokenExpiresAt) ? new Date(record.linkTokenExpiresAt) : null;
8643
+ if (!(record == null ? void 0 : record.linkToken) || !expiresAt || expiresAt <= /* @__PURE__ */ new Date()) {
8644
+ throw refusal("No link token for this store", 404);
8645
+ }
8646
+ return {
8647
+ message: "Link token read.",
8648
+ request: { shop },
8649
+ result: { token: ((_a = decrypt(record.linkToken)) == null ? void 0 : _a.token) ?? null }
8650
+ };
8651
+ },
8652
+ save: async ({ context }) => {
8653
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8654
+ const token = context == null ? void 0 : context.token;
8655
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8656
+ return {
8657
+ message: "Link token saved.",
8658
+ request: { shop },
8659
+ result: { ok: true },
8660
+ writes: [{
8661
+ collection: "shop",
8662
+ data: {
8663
+ $set: {
8664
+ linkToken: encrypt({ token }),
8665
+ linkTokenExpiresAt: new Date(Date.now() + LINK_TOKEN_TTL_MS)
8666
+ }
8667
+ },
8668
+ operation: "update",
8669
+ query: { shop }
8670
+ }]
8671
+ };
8672
+ }
8673
+ }
8674
+ },
5933
8675
  // Drawbridge sends its own notification email and SMS — see the private
5934
8676
  // `drawbridge` manifest. A vendor answering these would be a second sender,
5935
8677
  // which is the arrangement the platform sender replaced.
@@ -5991,8 +8733,8 @@ var shopify_default2 = {
5991
8733
  // access token still works, reconciles the scopes the store granted
5992
8734
  // against the ones the app now needs, and queues a webhook
5993
8735
  // reconciliation.
5994
- health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
5995
- var _a, _b, _c, _d, _e, _f;
8736
+ health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {}) => {
8737
+ var _a, _b, _c, _d;
5996
8738
  const request2 = {
5997
8739
  connectionId: workflow.connection,
5998
8740
  organizationId: workflow.organization,
@@ -6029,33 +8771,26 @@ var shopify_default2 = {
6029
8771
  shopCurrency = String(shopData.currency || "").toLowerCase() || null;
6030
8772
  sourceRepaired = true;
6031
8773
  }
6032
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6033
- adminAccessToken,
6034
- domain: connection2.shop
6035
- });
6036
- const usageLine = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
6037
- if (usageLine) metered = usageLine;
6038
8774
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
6039
8775
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
6040
- if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
6041
- const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
6042
- const accruals = await shopify.partner.getUsageChargeEvents({
6043
- appId: "gid://shopify/App/" + partner.partnerAppId,
6044
- first: 10,
6045
- occurredAtMin: ledgerFloor.toISOString(),
6046
- organizationId: partner.partnerOrgId,
6047
- partnerToken: partner.partnerToken,
6048
- shopId: "gid://shopify/Shop/" + source.id
6049
- });
6050
- const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
6051
- const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
6052
- if (accrued) metered = String(accrued.chargeId || accrued.id);
6053
- const stampedAt = (_c = boundaries.find(
8776
+ const ledgerFloor = new Date(Date.now() - LEDGER_WINDOW_MS);
8777
+ const verdict = await meterVerdict({
8778
+ adminAccessToken,
8779
+ ledgerFloor,
8780
+ partner,
8781
+ shop: connection2.shop,
8782
+ shopId: source == null ? void 0 : source.id,
8783
+ shopify
8784
+ });
8785
+ if (verdict.metered !== void 0) metered = verdict.metered;
8786
+ const { boundaries } = verdict;
8787
+ if (boundaries.length) {
8788
+ const stampedAt = (_a = boundaries.find(
6054
8789
  (event) => {
6055
8790
  var _a2;
6056
8791
  return String(event.chargeId || event.id) === String(((_a2 = connection2.source) == null ? void 0 : _a2.metered) || "");
6057
8792
  }
6058
- )) == null ? void 0 : _c.occurredAt;
8793
+ )) == null ? void 0 : _a.occurredAt;
6059
8794
  reconciliation = [];
6060
8795
  let windowStart = ledgerFloor;
6061
8796
  for (const boundary of boundaries) {
@@ -6105,7 +8840,7 @@ var shopify_default2 = {
6105
8840
  }
6106
8841
  });
6107
8842
  } else {
6108
- (_d = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _d.call(logger2, new Error(
8843
+ (_b = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _b.call(logger2, new Error(
6109
8844
  "shopify.usage.reconciliation.mismatch on shop " + connection2.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"
6110
8845
  ), {
6111
8846
  extra: {
@@ -6124,13 +8859,13 @@ var shopify_default2 = {
6124
8859
  }
6125
8860
  } catch (probeError) {
6126
8861
  billingProbeFailed = true;
6127
- (_e = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _e.call(logger2, "shopify.health.billing.probe.failed", {
8862
+ (_c = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _c.call(logger2, "shopify.health.billing.probe.failed", {
6128
8863
  connectionId: connection2.id,
6129
8864
  message: probeError == null ? void 0 : probeError.message,
6130
8865
  shop: connection2.shop
6131
8866
  });
6132
8867
  }
6133
- if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection2.source) == null ? void 0 : _f.metered) ?? void 0)) {
8868
+ if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection2.source) == null ? void 0 : _d.metered) ?? void 0)) {
6134
8869
  writes.push({
6135
8870
  collection: "connection",
6136
8871
  data: {
@@ -6265,13 +9000,13 @@ var shopify_default2 = {
6265
9000
  // They are the same two questions every other vendor answers through
6266
9001
  // resources.*, so they answer them the same way now.
6267
9002
  //
6268
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
9003
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
6269
9004
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
6270
9005
  // and nothing else — no controller, no collection access. Resolving the
6271
9006
  // credential is the caller's job because it is Drawbridge's job: the
6272
9007
  // admin token refreshes and writes itself back, which is service work,
6273
9008
  // not vendor work.
6274
- products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
9009
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify = client } = {}) => {
6275
9010
  var _a, _b, _c, _d;
6276
9011
  const products = await shopify.storefront.getProducts({
6277
9012
  cursor,
@@ -6293,7 +9028,7 @@ var shopify_default2 = {
6293
9028
  }
6294
9029
  };
6295
9030
  },
6296
- promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
9031
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify = client } = {}) => {
6297
9032
  var _a, _b;
6298
9033
  const discounts = await shopify.admin.getDiscounts({
6299
9034
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -6444,6 +9179,13 @@ var shopify_default2 = {
6444
9179
  key: "Shopify Order Tracking",
6445
9180
  queue: "connection",
6446
9181
  system: true,
9182
+ // THE TRIGGER THE REGISTER JOB HAND-WROTE. It is byte-identical to
9183
+ // the one on every stored workflow, because these documents exist on
9184
+ // prod with thousands of runs behind them and provisioning is
9185
+ // idempotent on (connection, system, title) — a different trigger
9186
+ // here would not be picked up by the existing rows anyway, and a
9187
+ // different TITLE would provision a second workflow for every store.
9188
+ trigger: { event: "shopify.order.create", type: "webhook" },
6447
9189
  type: "step.commerce.order.record"
6448
9190
  })
6449
9191
  },
@@ -6454,6 +9196,15 @@ var shopify_default2 = {
6454
9196
  key: "Shopify Product Sync",
6455
9197
  queue: "connection",
6456
9198
  system: true,
9199
+ // `shopify.product.update`, NOT `product.insert`. The plan named the
9200
+ // latter, and it is the right long-term answer — a Drawbridge product
9201
+ // row appearing should pull the vendor's copy, rather than
9202
+ // stream/product.js branching on the slug and enqueueing a
9203
+ // vendor-named queue. But a workflow carries ONE trigger, this one is
9204
+ // stored on prod against the webhook, and changing a live workflow's
9205
+ // trigger is a data migration. So the declaration matches what exists
9206
+ // and `product.insert` stays a separate, migration-gated change.
9207
+ trigger: { event: "shopify.product.update", type: "webhook" },
6457
9208
  type: "step.commerce.product.sync"
6458
9209
  })
6459
9210
  }
@@ -6475,26 +9226,37 @@ var shopify_default2 = {
6475
9226
  type: "step.connection.health.check"
6476
9227
  })
6477
9228
  },
6478
- // Audit-only. The "Shopify Token Activity" system workflow lists these
6479
- // for descriptive grouping, but its audit step docs are written manually
6480
- // at OAuth time — the workflow is never dispatched. Routing is declared
6481
- // defensively so that if it ever IS dispatched, the job lands on a real
6482
- // queue and the handler lookup misses cleanly instead of throwing
6483
- // "Unknown step type".
9229
+ // THE ONE WORKFLOW THAT CARRIES TWO STEPS, and the shape the plan called
9230
+ // the single genuinely new thing in it.
9231
+ //
9232
+ // Every other system step is its own workflow, titled by its own `key`.
9233
+ // These two are "Shopify Token Activity", one row in the merchant's list
9234
+ // with both steps in it — so they declare `workflow`, which names the
9235
+ // title, the trigger and the ORDER, and the provisioner groups on it.
9236
+ // Without that key the pair could only be provisioned by the hand-written
9237
+ // spec in drawbridge-sync's register job, which is exactly why that spec
9238
+ // outlived every other one.
9239
+ //
9240
+ // AUDIT-ONLY. The step documents are written manually at OAuth time and
9241
+ // the workflow is never dispatched; `queue` is declared defensively so
9242
+ // that if it ever IS, the job lands on a real queue and the handler lookup
9243
+ // misses cleanly rather than throwing "Unknown step type".
6484
9244
  token: {
6485
9245
  exchange: () => ({
6486
9246
  description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
6487
9247
  key: "Shopify Token Exchange",
6488
9248
  queue: "connection",
6489
9249
  system: true,
6490
- type: "step.connection.token.exchange"
9250
+ type: "step.connection.token.exchange",
9251
+ workflow: { key: "Shopify Token Activity", order: 0, trigger: { event: "shopify.token", type: "event" } }
6491
9252
  }),
6492
9253
  refresh: () => ({
6493
9254
  description: "Records a token rotation. Audit only \u2014 never dispatched.",
6494
9255
  key: "Shopify Token Refresh",
6495
9256
  queue: "connection",
6496
9257
  system: true,
6497
- type: "step.connection.token.refresh"
9258
+ type: "step.connection.token.refresh",
9259
+ workflow: { key: "Shopify Token Activity", order: 1, trigger: { event: "shopify.token", type: "event" } }
6498
9260
  })
6499
9261
  }
6500
9262
  }
@@ -6580,7 +9342,7 @@ var shopify_default2 = {
6580
9342
  };
6581
9343
 
6582
9344
  // lib/connections/manifests/webhook.js
6583
- import crypto2 from "crypto";
9345
+ import crypto3 from "crypto";
6584
9346
 
6585
9347
  // lib/safe-http.js
6586
9348
  import dns2 from "dns";
@@ -6753,7 +9515,7 @@ var signature = ({ body, settings }) => {
6753
9515
  return [
6754
9516
  "t=" + timestamp,
6755
9517
  ...[settings.secret, ...previous].map(
6756
- (secret) => "v1=" + crypto2.createHmac(generated.algorithm, secret).update(payload).digest(generated.encoding)
9518
+ (secret) => "v1=" + crypto3.createHmac(generated.algorithm, secret).update(payload).digest(generated.encoding)
6757
9519
  )
6758
9520
  ].join(",");
6759
9521
  };
@@ -6798,14 +9560,15 @@ var webhook_default = {
6798
9560
  "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
6799
9561
  "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."
6800
9562
  ],
6801
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6802
- // The dashboard already carries generic reasons for every way an
6803
- // authorization can fail declined, expired state, a vendor we are not set
6804
- // up for and a sentence here is an ADDITION to those, for a vendor whose
6805
- // failures need saying differently. This one's do not.
9563
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
9564
+ // nothing is authorized against a third party, so neither `denied` nor
9565
+ // `invalid` has a state it could describe. The failures this connection
9566
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
9567
+ // those reach them through the connection's own errors rather than through
9568
+ // connect copy.
6806
9569
  //
6807
- // Answered rather than omitted, because `errors` existed on two of six
6808
- // manifests and there was no way to tell "nothing to add" from "nobody
9570
+ // `false` is the recorded decision, not silence: `errors` existed on two of
9571
+ // six manifests and there was no way to tell "nothing to add" from "nobody
6809
9572
  // wrote it".
6810
9573
  errors: false,
6811
9574
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -6949,7 +9712,7 @@ var webhook_default = {
6949
9712
  // The connect prompt only where connecting is not already the card's whole
6950
9713
  // story: a disconnected or errored document renders a Connect action itself,
6951
9714
  // and a task repeating it talks over the button.
6952
- tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
9715
+ tasks: ({ settings, status } = {}) => status === "error" ? [] : (settings == null ? void 0 : settings.secret) ? [
6953
9716
  {
6954
9717
  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.',
6955
9718
  title: "Requests must be verified",
@@ -7036,6 +9799,77 @@ var attentive_default3 = {
7036
9799
  }
7037
9800
  };
7038
9801
 
9802
+ // lib/connections/icons/brightdata.js
9803
+ var brightdata_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
9804
+ <rect width="500" height="500" fill="#0F58FF"/>
9805
+ <circle cx="250" cy="250" r="52" fill="white"/>
9806
+ <rect x="234" y="70" width="32" height="96" rx="16" fill="white"/>
9807
+ <rect x="234" y="334" width="32" height="96" rx="16" fill="white"/>
9808
+ <rect x="70" y="234" width="96" height="32" rx="16" fill="white"/>
9809
+ <rect x="334" y="234" width="96" height="32" rx="16" fill="white"/>
9810
+ <rect x="117" y="140" width="32" height="96" rx="16" transform="rotate(-45 117 140)" fill="white"/>
9811
+ <rect x="304" y="327" width="32" height="96" rx="16" transform="rotate(-45 304 327)" fill="white"/>
9812
+ <rect x="360" y="117" width="32" height="96" rx="16" transform="rotate(45 360 117)" fill="white"/>
9813
+ <rect x="173" y="304" width="32" height="96" rx="16" transform="rotate(45 173 304)" fill="white"/>
9814
+ </svg>`;
9815
+
9816
+ // lib/connections/vendors/brightdata.js
9817
+ var brightdata_default2 = {
9818
+ fields: [
9819
+ {
9820
+ credential: "BRIGHTDATA_BROWSER_URI",
9821
+ input: "password",
9822
+ key: "browserUri",
9823
+ // A wss:// endpoint with the account's user and password in its
9824
+ // authority, which is why it is a password field rather than a url one:
9825
+ // the whole string is the credential.
9826
+ label: "Scraping Browser endpoint",
9827
+ message: "The wss:// Scraping Browser endpoint, carrying its own credentials. Used to render pages that need a real browser.",
9828
+ redact: true,
9829
+ required: true
9830
+ },
9831
+ {
9832
+ credential: "BRIGHTDATA_UNLOCKER_API_KEY",
9833
+ input: "password",
9834
+ key: "unlockerApiKey",
9835
+ label: "Web Unlocker API key",
9836
+ // NO EXPIRY FIELD, and that is a decision rather than an omission. Bright
9837
+ // Data offers an `Unlimited` expiry and recommends against it; Drawbridge
9838
+ // takes the unlimited key deliberately, because there is no way to rotate
9839
+ // this programmatically and a date nothing can act on is a date that only
9840
+ // surfaces after it has already broken scraping. If a fixed expiry is ever
9841
+ // taken instead, the field to add is a stored date and a warning ahead of
9842
+ // it — not a rotation, which the vendor cannot support.
9843
+ 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.",
9844
+ redact: true,
9845
+ required: true
9846
+ },
9847
+ {
9848
+ credential: "BRIGHTDATA_UNLOCKER_ZONE",
9849
+ input: "text",
9850
+ key: "unlockerZone",
9851
+ label: "Web Unlocker zone",
9852
+ // Not secret — a zone is a label on the account, and it is shown rather
9853
+ // than redacted so an operator can confirm which one is in use without
9854
+ // clearing the field to read it.
9855
+ message: "The zone name the Unlocker requests run against.",
9856
+ required: true
9857
+ }
9858
+ ],
9859
+ icon: brightdata_default,
9860
+ name: "Bright Data",
9861
+ slug: "brightdata",
9862
+ // WHERE THE FACTS CAME FROM, read 2026-09-16.
9863
+ urls: {
9864
+ api: "https://docs.brightdata.com/api-reference/authentication",
9865
+ dashboard: "https://brightdata.com/cp/setting/users",
9866
+ // Bright Data has no scope model — a key carries the account's own access —
9867
+ // so there is nothing to link. `false` is the recorded answer rather than a
9868
+ // missing key.
9869
+ scopes: false
9870
+ }
9871
+ };
9872
+
7039
9873
  // lib/connections/vendors/hubspot.js
7040
9874
  var hubspot_default2 = {
7041
9875
  // WHAT AN ADMIN TYPES on the provider screen. `credential` names the env var a
@@ -7411,7 +10245,7 @@ var checkIcon = (owner, icon) => {
7411
10245
  }
7412
10246
  };
7413
10247
  var vendors = Object.freeze(Object.fromEntries(
7414
- [attentive_default3, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
10248
+ [attentive_default3, brightdata_default2, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
7415
10249
  ));
7416
10250
  var buildVendor = (vendor) => {
7417
10251
  if (!(vendor == null ? void 0 : vendor.slug)) throw new Error("A vendor needs a slug");
@@ -7438,7 +10272,7 @@ for (const [slug2, vendor] of Object.entries(vendors)) {
7438
10272
  buildVendor(vendor);
7439
10273
  }
7440
10274
  var build = (manifest) => {
7441
- 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;
10275
+ 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;
7442
10276
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
7443
10277
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
7444
10278
  if (manifest == null ? void 0 : manifest.private) throw new Error(manifest.slug + " declares private \u2014 that is feature : false now");
@@ -7704,11 +10538,22 @@ var build = (manifest) => {
7704
10538
  manifest.slug + " step " + type2 + " may be triggered by " + trigger + ", which is not a trigger \u2014 one of " + Object.keys(TRIGGERS).join(", ")
7705
10539
  );
7706
10540
  }
7707
- if (declared2.trigger) {
7708
- const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === declared2.trigger.event && TRIGGERS[key].type === declared2.trigger.type)];
10541
+ if (declared2.workflow) {
10542
+ if (!declared2.workflow.key) {
10543
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no key \u2014 the title several steps share");
10544
+ }
10545
+ if (typeof declared2.workflow.order !== "number") {
10546
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no order \u2014 two steps in one row need a sequence");
10547
+ }
10548
+ if (declared2.trigger) {
10549
+ 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");
10550
+ }
10551
+ }
10552
+ for (const candidate of [declared2.trigger, (_y = declared2.workflow) == null ? void 0 : _y.trigger].filter(Boolean)) {
10553
+ const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === candidate.event && TRIGGERS[key].type === candidate.type)];
7709
10554
  if (!known2) {
7710
10555
  throw new Error(
7711
- manifest.slug + " step " + type2 + " declares trigger { event : " + declared2.trigger.event + ", type : " + declared2.trigger.type + " }, which nothing dispatches"
10556
+ manifest.slug + " step " + type2 + " declares trigger { event : " + candidate.event + ", type : " + candidate.type + " }, which nothing dispatches"
7712
10557
  );
7713
10558
  }
7714
10559
  }