@drawbridge/drawbridge-utils 0.0.176 → 0.0.177

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 +3077 -228
  8. package/dist/connections/index.d.cts +13 -4
  9. package/dist/connections/index.d.ts +13 -4
  10. package/dist/connections/index.js +3107 -255
  11. package/dist/features.cjs +2940 -226
  12. package/dist/features.d.cts +13 -4
  13. package/dist/features.d.ts +13 -4
  14. package/dist/features.js +2980 -260
  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-B8JhYfvU.d.ts → index-B546oDNo.d.ts} +2837 -956
  20. package/dist/{index-qY18QITf.d.cts → index-C59xHago.d.cts} +2837 -956
  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 +2939 -225
  28. package/dist/plans.d.cts +13 -4
  29. package/dist/plans.d.ts +13 -4
  30. package/dist/plans.js +2980 -260
  31. package/dist/pricing.cjs +2972 -258
  32. package/dist/pricing.d.cts +13 -4
  33. package/dist/pricing.d.ts +13 -4
  34. package/dist/pricing.js +2977 -257
  35. package/dist/providers.cjs +2942 -247
  36. package/dist/providers.d.cts +12 -3
  37. package/dist/providers.d.ts +12 -3
  38. package/dist/providers.js +2946 -245
  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/plans.cjs CHANGED
@@ -163,6 +163,10 @@ var HOOKS = Object.freeze({
163
163
  "code",
164
164
  // Create the buyer at the vendor, so an order can be attributed to them.
165
165
  "customer",
166
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
167
+ // campaign's product list is rendered rather than stored, because stock is
168
+ // the one product fact that is stale the instant it is written down.
169
+ "inventory",
166
170
  // An order arrived at the vendor: attribute it, record it, update totals.
167
171
  "order",
168
172
  // Pull product data across on a vendor update.
@@ -230,7 +234,19 @@ var HOOKS = Object.freeze({
230
234
  organizations: Object.freeze(["add", "list", "remove"]),
231
235
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
232
236
  // declares the plan they are being charged on.
233
- plan: Object.freeze(["cancel", "reselect"]),
237
+ //
238
+ // `verify` is the odd one of the three: not something a merchant does, but
239
+ // the question "is this store's billing actually metered right now". It is
240
+ // here because it is the same subject — and because the answer was being
241
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
242
+ // approval webhook, whose copy said in its own comment that it matched the
243
+ // other and did not.
244
+ // `charge` and `verify` are the two that are not merchant actions: one
245
+ // sends a usage charge to the vendor's meter, the other asks whether that
246
+ // meter is actually working. Both are here because both are the same
247
+ // subject — what the merchant pays the vendor — and both were being done
248
+ // from drawbridge-sync with a direct import of the vendor's client.
249
+ plan: Object.freeze(["cancel", "charge", "reselect", "verify"]),
234
250
  // The embedded app's own session, stored by us on the vendor's behalf.
235
251
  session: Object.freeze(["clear", "read", "save"]),
236
252
  // The one-time code that hands a merchant from the embedded app into
@@ -315,7 +331,7 @@ var ANSWER_KEYS = Object.freeze([
315
331
  "usage"
316
332
  ].sort());
317
333
  var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
318
- var INSTALL_SIGNATURE = Object.freeze({ options: ["read", "request"], props: ["context"] });
334
+ var INSTALL_SIGNATURE = Object.freeze({ options: ["read"], props: ["context"] });
319
335
  var LIFECYCLE_SIGNATURE = Object.freeze({ options: ["dispatch"], props: ["context"] });
320
336
  var RESOURCE_SIGNATURE = Object.freeze({
321
337
  options: ["fetcher", "shopify"],
@@ -332,15 +348,30 @@ var HOOK_SLOT_PROPS = Object.freeze({
332
348
  // the second bag would be silently discarded, which is exactly the
333
349
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
334
350
  "auth.token": false,
335
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
336
- // api/route/subdomain.js writes nothing, which is why it never became a hook
337
- // so its signature mirrors `code`, its nearest sibling, and moving the
338
- // route here is a deliberate widening of this line rather than a silent one.
339
- "commerce.cart": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
351
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
352
+ // not for an organization inside a workflow, so it takes no `connection` and
353
+ // no `step`. It reads the storefront with the store's own storefront token
354
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
355
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
356
+ // already carries them, because a credential is a fact of the run rather
357
+ // than a service.
358
+ "commerce.cart": Object.freeze({
359
+ options: ["fetcher", "shopify"],
360
+ props: ["clientId", "clientSecret", "context", "settings"]
361
+ }),
340
362
  "commerce.code": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
341
363
  "commerce.customer": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["connection", "context"] }),
364
+ "commerce.inventory": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context"] }),
342
365
  "commerce.order": Object.freeze({ options: ["conversionRate", "logger", "mintId", "read"], props: ["connection", "context"] }),
343
- "commerce.product": Object.freeze({ options: ["mintId", "read"], props: ["connection", "context", "workflow"] }),
366
+ // THREE OPERATIONS, one slot — record, sync, variants. `settings` and
367
+ // `adminToken` arrived with the pull half: the storefront read needs the
368
+ // store's storefront token and the product-feedback send needs its admin one,
369
+ // and both are resolved by the caller because minting either needs a
370
+ // controller.
371
+ "commerce.product": Object.freeze({
372
+ options: ["adminToken", "mintId", "read", "shopify"],
373
+ props: ["connection", "context", "settings", "workflow"]
374
+ }),
344
375
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
345
376
  // person, not the lead document. `remove` called it `email` and `sync` called
346
377
  // it `doc` on one vendor and `lead` on three — three names for two facts.
@@ -353,19 +384,58 @@ var HOOK_SLOT_PROPS = Object.freeze({
353
384
  "inbound.process": Object.freeze({ options: ["dispatch"], props: ["context"] }),
354
385
  "inbound.receive": Object.freeze({ options: [], props: ["channel", "event", "headers", "payload"] }),
355
386
  "inbound.verify": Object.freeze({ options: [], props: ["body", "channel", "headers", "secret", "url"] }),
356
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
357
- // routes in api/route/shopify-embedded.js become, and the signature is
358
- // deliberately narrow: `context` is the request's own body, `read` and
359
- // `request` are the only services an install surface has been shown to need.
360
- // A route that cannot be expressed inside this line is a finding to bring
361
- // back here, not a name to add at the call site.
387
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
388
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
389
+ // of them; the options are where they differ, and every difference below was
390
+ // forced by a line in those routes rather than chosen.
391
+ //
392
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
393
+ // token slots that write, the two clears, and both account slots answer from
394
+ // the shared install record alone.
395
+ //
396
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
397
+ // Drawbridge owns that the manifest must not:
398
+ //
399
+ // conversionRate, planTitle what Drawbridge charges this organization.
400
+ // The org list renders both, and importing the
401
+ // plan catalogue here is the cycle
402
+ // features -> connections -> shopify -> plans
403
+ // -> features that was just broken.
404
+ // currencies which settlement currencies Drawbridge can
405
+ // bill. A store outside them fails the
406
+ // connection validator INSIDE the transaction,
407
+ // which surfaces as an opaque 500, so `add`
408
+ // checks first and says so.
409
+ // mintId the connection's id, known before the write
410
+ // that creates it, because the organization's
411
+ // own refs have to name it in the same
412
+ // transaction.
413
+ // shopify the vendor. Four slots call the store: two
414
+ // read the live plan, two release it.
415
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
416
+ // `reselect` is the only slot with one. See its
417
+ // implementation for why the order is the point.
362
418
  "install.account.link": INSTALL_SIGNATURE,
363
419
  "install.account.unlink": INSTALL_SIGNATURE,
364
- "install.organizations.add": INSTALL_SIGNATURE,
365
- "install.organizations.list": INSTALL_SIGNATURE,
420
+ "install.organizations.add": Object.freeze({
421
+ options: ["currencies", "mintId", "read", "shopify"],
422
+ props: ["context"]
423
+ }),
424
+ "install.organizations.list": Object.freeze({
425
+ options: ["conversionRate", "planTitle", "read", "shopify"],
426
+ props: ["context"]
427
+ }),
366
428
  "install.organizations.remove": INSTALL_SIGNATURE,
367
- "install.plan.cancel": INSTALL_SIGNATURE,
368
- "install.plan.reselect": INSTALL_SIGNATURE,
429
+ "install.plan.cancel": Object.freeze({ options: ["read", "shopify"], props: ["context"] }),
430
+ // `manifest` because the handle this charge bills under is the vendor's own
431
+ // declaration (auth.install.billing.handle), and checking the job against the
432
+ // declaration is what the enqueuer-checks-itself version could not do.
433
+ "install.plan.charge": Object.freeze({
434
+ options: ["shopify"],
435
+ props: ["clientId", "clientSecret", "context", "manifest"]
436
+ }),
437
+ "install.plan.reselect": Object.freeze({ options: ["mark", "read", "shopify"], props: ["context"] }),
438
+ "install.plan.verify": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["context"] }),
369
439
  "install.session.clear": INSTALL_SIGNATURE,
370
440
  "install.session.read": INSTALL_SIGNATURE,
371
441
  "install.session.save": INSTALL_SIGNATURE,
@@ -524,6 +594,52 @@ var ERROR_SOURCES = Object.freeze({
524
594
  "shopify.register.webhooks": true
525
595
  });
526
596
 
597
+ // lib/encrypt.js
598
+ var import_crypto2 = __toESM(require("crypto"), 1);
599
+
600
+ // lib/token.js
601
+ var import_crypto = __toESM(require("crypto"), 1);
602
+ var generate = (bytes = 32, encoding = "base64url") => {
603
+ const buf = import_crypto.default.randomBytes(bytes);
604
+ return encoding ? buf.toString(encoding) : buf;
605
+ };
606
+ var hash = (value, key) => {
607
+ if (!value) return null;
608
+ if (key) {
609
+ return import_crypto.default.createHmac("sha256", key).update(String(value)).digest("base64url");
610
+ }
611
+ return import_crypto.default.createHash("sha256").update(String(value)).digest("base64url");
612
+ };
613
+
614
+ // lib/encrypt.js
615
+ var ALGORITHM = "aes-256-gcm";
616
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
617
+ var encrypt = (value) => {
618
+ const iv = generate(12, null);
619
+ const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
620
+ const data2 = Buffer.concat([
621
+ cipher.update(JSON.stringify(value), "utf8"),
622
+ cipher.final()
623
+ ]);
624
+ const tag = cipher.getAuthTag();
625
+ return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
626
+ };
627
+ var decrypt = (value) => {
628
+ if (typeof value !== "string") return value;
629
+ const [ivHex, tagHex, dataHex] = value.split(":");
630
+ const decipher = import_crypto2.default.createDecipheriv(
631
+ ALGORITHM,
632
+ getKey(),
633
+ Buffer.from(ivHex, "hex")
634
+ );
635
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
636
+ const result = Buffer.concat([
637
+ decipher.update(Buffer.from(dataHex, "hex")),
638
+ decipher.final()
639
+ ]);
640
+ return JSON.parse(result.toString("utf8"));
641
+ };
642
+
527
643
  // lib/connections/oauth.js
528
644
  var import_node_crypto = require("crypto");
529
645
  var credentials = ({ basic, clientId, clientSecret }) => basic ? {
@@ -550,10 +666,10 @@ var authToken = async ({
550
666
  const renewing = !code2;
551
667
  if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
552
668
  if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
553
- const client = credentials({ basic, clientId, clientSecret });
669
+ const client2 = credentials({ basic, clientId, clientSecret });
554
670
  const response = await fetcher(descriptor.endpoints.token, {
555
671
  body: new URLSearchParams({
556
- ...client.body,
672
+ ...client2.body,
557
673
  ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
558
674
  code: decodeURIComponent(String(code2 || "").trim()),
559
675
  grant_type: "authorization_code",
@@ -561,7 +677,7 @@ var authToken = async ({
561
677
  ...descriptor.pkce && { code_verifier: verifier }
562
678
  }
563
679
  }),
564
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
680
+ headers: { "content-type": "application/x-www-form-urlencoded", ...client2.headers },
565
681
  method: "POST",
566
682
  signal: AbortSignal.timeout(15e3)
567
683
  });
@@ -956,9 +1072,18 @@ var scaffold_default = {
956
1072
  commerce: {
957
1073
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
958
1074
  // the last api surface Shopify owns outside a hook.
959
- cart: async ({ connection: connection2, context }, { read } = {}) => ANSWER,
1075
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
1076
+ // page, not for an organization inside a workflow. It reads the storefront
1077
+ // with the store's storefront token and mints a checkout token from
1078
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
1079
+ // as props — the way auth.disconnect already carries it.
1080
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify } = {}) => ANSWER,
960
1081
  code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => ANSWER,
961
1082
  customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => ANSWER,
1083
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
1084
+ // fact that is stale the instant it is written down, so the campaign
1085
+ // product list asks the vendor and caches the answer for a minute.
1086
+ inventory: async ({ connection: connection2, context }, { adminToken, shopify } = {}) => ANSWER,
962
1087
  order: async ({ connection: connection2, context }, { logger, mintId, read } = {}) => ANSWER,
963
1088
  // THIS IS WHERE `product.insert` LANDS.
964
1089
  //
@@ -980,7 +1105,7 @@ var scaffold_default = {
980
1105
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
981
1106
  // a log. Phase 1 closes it and build() checks every trigger against it.
982
1107
  product: {
983
- hook: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => ANSWER,
1108
+ hook: async ({ connection: connection2, context, settings, workflow }, { adminToken, mintId, read, shopify } = {}) => ANSWER,
984
1109
  step: () => ({
985
1110
  description: "Syncs product data from the vendor when a product changes.",
986
1111
  key: "Vendor Product Sync",
@@ -1128,8 +1253,19 @@ var scaffold_default = {
1128
1253
  // Vendor billing, the only group that is not generic. Pairs with
1129
1254
  // auth.install.billing, which declares what the merchant is being charged.
1130
1255
  plan: {
1131
- cancel: async ({ context }, { dispatch, read } = {}) => ANSWER,
1132
- reselect: async ({ context }, { dispatch, read } = {}) => ANSWER
1256
+ cancel: async ({ context }, { read, shopify } = {}) => ANSWER,
1257
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
1258
+ // sent to the vendor's meter. The credential pair rides in as props
1259
+ // because the charge authenticates as the APP, not as the store.
1260
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify } = {}) => ANSWER,
1261
+ // `mark` is the one write a description cannot carry: the marker must
1262
+ // be persisted BEFORE the approval is released, and a described write
1263
+ // lands after the hook returns.
1264
+ reselect: async ({ context }, { mark, read, shopify } = {}) => ANSWER,
1265
+ // NOT A MERCHANT ACTION — the question "is this store's billing
1266
+ // actually metered right now", asked by the daily health check and by
1267
+ // the vendor's own approval webhook, which used to hand-copy it.
1268
+ verify: async ({ context }, { adminToken, read, shopify } = {}) => ANSWER
1133
1269
  },
1134
1270
  // The embedded app's own session, stored by us on the vendor's behalf.
1135
1271
  session: {
@@ -1615,16 +1751,19 @@ var attentive_default2 = {
1615
1751
  "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.",
1616
1752
  "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."
1617
1753
  ],
1618
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
1619
- // The dashboard already carries generic reasons for every way an
1620
- // authorization can fail — declined, expired state, a vendor we are not set
1621
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
1622
- // failures need saying differently. This one's do not.
1754
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
1755
+ // consent screen has two ways to end: they said no, or something between
1756
+ // here and the vendor went wrong.
1623
1757
  //
1624
- // Answered rather than omitted, because `errors` existed on two of six
1625
- // manifests and there was no way to tell "nothing to add" from "nobody
1626
- // wrote it".
1627
- errors: false,
1758
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
1759
+ // authorization"; a merchant connecting two accounts in one sitting needs
1760
+ // to know which one this was.
1761
+ errors: {
1762
+ connect: {
1763
+ denied: "The Attentive authorization was declined, so nothing was connected.",
1764
+ invalid: "We couldn't complete the Attentive connection. Try connecting again."
1765
+ }
1766
+ },
1628
1767
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
1629
1768
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
1630
1769
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -2152,6 +2291,15 @@ var readEventHeader = ({ descriptor, headers }) => ({ result: { event: headers[d
2152
2291
  var DEFAULT_TIMEOUT_MS = 15e3;
2153
2292
  var request = async ({
2154
2293
  body,
2294
+ // THE TRANSPORT, injectable and defaulted to the real one.
2295
+ //
2296
+ // Every other vendor client in this package takes a `fetcher` — it is how
2297
+ // klaviyo, mailchimp and attentive are tested without a network, and it is a
2298
+ // declared HOOK_OPTION. The Shopify client had no seam at all, so the only way
2299
+ // to test a hook that used it was to inject the whole SDK namespace; that
2300
+ // injection existed to work around an import cycle, and when the cycle went
2301
+ // the tests lost their only hold. This is the seam they should have had.
2302
+ fetcher = fetch,
2155
2303
  headers = {},
2156
2304
  method = "GET",
2157
2305
  query,
@@ -2165,7 +2313,7 @@ var request = async ({
2165
2313
  }
2166
2314
  ;
2167
2315
  const isForm = type === "form";
2168
- const response = await fetch(fullUrl.toString(), {
2316
+ const response = await fetcher(fullUrl.toString(), {
2169
2317
  method,
2170
2318
  headers: {
2171
2319
  "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
@@ -2622,15 +2770,12 @@ var drawbridge_default2 = {
2622
2770
  description: [
2623
2771
  "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2624
2772
  ],
2625
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2626
- // The dashboard already carries generic reasons for every way an
2627
- // authorization can fail declined, expired state, a vendor we are not set
2628
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2629
- // failures need saying differently. This one's do not.
2773
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
2774
+ // own manifest always on, private, never in the catalogue — and it exists
2775
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
2776
+ // describe a consent screen this connection does not have.
2630
2777
  //
2631
- // Answered rather than omitted, because `errors` existed on two of six
2632
- // manifests and there was no way to tell "nothing to add" from "nobody
2633
- // wrote it".
2778
+ // `false` is the recorded decision, not silence.
2634
2779
  errors: false,
2635
2780
  excerpt: "The steps Drawbridge runs itself.",
2636
2781
  guide: [
@@ -3390,10 +3535,10 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3390
3535
 
3391
3536
  // lib/connections/scopes.js
3392
3537
  var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3393
- var missingScopes = ({ granted, required }) => {
3538
+ var missingScopes = ({ granted, required: required2 }) => {
3394
3539
  if (granted === null || granted === void 0 || granted === "") return null;
3395
3540
  const held = new Set(list(granted));
3396
- return list(required).filter((scope) => !held.has(scope));
3541
+ return list(required2).filter((scope) => !held.has(scope));
3397
3542
  };
3398
3543
 
3399
3544
  // lib/connections/manifests/klaviyo.js
@@ -4292,16 +4437,19 @@ var mailchimp_default2 = {
4292
4437
  "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.",
4293
4438
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
4294
4439
  ],
4295
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4296
- // The dashboard already carries generic reasons for every way an
4297
- // authorization can fail — declined, expired state, a vendor we are not set
4298
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4299
- // failures need saying differently. This one's do not.
4440
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
4441
+ // consent screen has two ways to end: they said no, or something between
4442
+ // here and the vendor went wrong.
4300
4443
  //
4301
- // Answered rather than omitted, because `errors` existed on two of six
4302
- // manifests and there was no way to tell "nothing to add" from "nobody
4303
- // wrote it".
4304
- errors: false,
4444
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
4445
+ // authorization"; a merchant connecting two accounts in one sitting needs
4446
+ // to know which one this was.
4447
+ errors: {
4448
+ connect: {
4449
+ denied: "The Mailchimp authorization was declined, so nothing was connected.",
4450
+ invalid: "We couldn't complete the Mailchimp connection. Try connecting again."
4451
+ }
4452
+ },
4305
4453
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
4306
4454
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
4307
4455
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -4400,7 +4548,7 @@ var mailchimp_default2 = {
4400
4548
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4401
4549
  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);
4402
4550
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4403
- const hash = subscriberHash(email);
4551
+ const hash2 = subscriberHash(email);
4404
4552
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4405
4553
  const lastName = restOfName.join(" ");
4406
4554
  const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
@@ -4409,7 +4557,7 @@ var mailchimp_default2 = {
4409
4557
  ...lastName && { LNAME: lastName },
4410
4558
  ...phone && { PHONE: phone }
4411
4559
  };
4412
- const member = await api3("/lists/" + audience + "/members/" + hash, {
4560
+ const member = await api3("/lists/" + audience + "/members/" + hash2, {
4413
4561
  dc: settings == null ? void 0 : settings.dc,
4414
4562
  fetcher,
4415
4563
  method: "PUT",
@@ -4450,7 +4598,7 @@ var mailchimp_default2 = {
4450
4598
  status: joined.has(entry.id) ? "active" : "inactive"
4451
4599
  }));
4452
4600
  if (tags.length > 0) {
4453
- await api3("/lists/" + audience + "/members/" + hash + "/tags", {
4601
+ await api3("/lists/" + audience + "/members/" + hash2 + "/tags", {
4454
4602
  dc: settings == null ? void 0 : settings.dc,
4455
4603
  fetcher,
4456
4604
  method: "POST",
@@ -4461,12 +4609,12 @@ var mailchimp_default2 = {
4461
4609
  }
4462
4610
  return {
4463
4611
  // Merged into `context` for later steps in this run.
4464
- context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
4612
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 },
4465
4613
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4466
4614
  request: request2,
4467
4615
  // Recorded on the run for support to read back, not a write
4468
4616
  // instruction — the hook has already written what it needed to.
4469
- response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
4617
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 }
4470
4618
  };
4471
4619
  }
4472
4620
  },
@@ -4836,139 +4984,2030 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
4836
4984
  <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"/>
4837
4985
  </svg>`;
4838
4986
 
4839
- // lib/email.js
4840
- var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
4841
- var toCanonicalEmail = (value) => {
4842
- if (!value || typeof value !== "string") return null;
4843
- const email = value.trim().toLowerCase();
4844
- const at = email.lastIndexOf("@");
4845
- if (at < 1 || at === email.length - 1) return null;
4846
- let local = email.slice(0, at);
4847
- const domain = email.slice(at + 1);
4848
- const plus = local.indexOf("+");
4849
- if (plus > 0) local = local.slice(0, plus);
4850
- if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
4851
- if (!local) return null;
4852
- return local + "@" + domain;
4853
- };
4987
+ // lib/shopify/admin.js
4988
+ var admin_exports = {};
4989
+ __export(admin_exports, {
4990
+ adminFetch: () => adminFetch,
4991
+ createDiscountCode: () => createDiscountCode,
4992
+ createUsageRecord: () => createUsageRecord,
4993
+ createUsageSubscription: () => createUsageSubscription,
4994
+ getActiveAppSubscriptions: () => getActiveAppSubscriptions,
4995
+ getDiscounts: () => getDiscounts,
4996
+ getOrCreateCustomer: () => getOrCreateCustomer,
4997
+ getProductInventory: () => getProductInventory,
4998
+ sendProductResourceFeedback: () => sendProductResourceFeedback
4999
+ });
4854
5000
 
4855
- // lib/encrypt.js
4856
- var import_crypto = __toESM(require("crypto"), 1);
4857
- var ALGORITHM = "aes-256-gcm";
4858
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
4859
- var decrypt = (value) => {
4860
- if (typeof value !== "string") return value;
4861
- const [ivHex, tagHex, dataHex] = value.split(":");
4862
- const decipher = import_crypto.default.createDecipheriv(
4863
- ALGORITHM,
4864
- getKey(),
4865
- Buffer.from(ivHex, "hex")
4866
- );
4867
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
4868
- const result = Buffer.concat([
4869
- decipher.update(Buffer.from(dataHex, "hex")),
4870
- decipher.final()
4871
- ]);
4872
- return JSON.parse(result.toString("utf8"));
4873
- };
5001
+ // lib/shopify/constants.js
5002
+ var SHOPIFY_STOREFRONT_API_VERSION = "2025-01";
5003
+ var SHOPIFY_ADMIN_API_VERSION = "2026-04";
5004
+ var SHOPIFY_APP_API_VERSION = "unstable";
5005
+ var SHOPIFY_REQUIRED_SCOPES = [
5006
+ "read_customers",
5007
+ "write_customers",
5008
+ "read_discounts",
5009
+ "write_discounts",
5010
+ "read_orders",
5011
+ "read_products",
5012
+ "read_publications",
5013
+ "write_resource_feedbacks",
5014
+ "unauthenticated_read_checkouts",
5015
+ "unauthenticated_read_product_inventory",
5016
+ "unauthenticated_read_product_listings",
5017
+ "unauthenticated_write_checkouts"
5018
+ ];
5019
+ var REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1e3;
5020
+ var ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
5021
+ var PARTNER_API_VERSION = process.env.SHOPIFY_PARTNER_API_VERSION || "2026-07";
4874
5022
 
4875
- // lib/slugify.js
4876
- var import_slugify = __toESM(require("slugify"), 1);
4877
- var slugify = (value, nochars = false) => {
4878
- const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
4879
- const replacement = nochars ? "" : "-";
4880
- return (0, import_slugify.default)(
4881
- value,
4882
- {
4883
- remove,
4884
- replacement,
4885
- lower: true,
4886
- trim: true
5023
+ // lib/shopify/admin.js
5024
+ var adminUrl = (domain) => `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}`;
5025
+ var adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
5026
+ var _a;
5027
+ const { data: data2, errors } = await request({
5028
+ fetcher,
5029
+ method: "POST",
5030
+ url: adminUrl(domain) + "/graphql.json",
5031
+ headers: {
5032
+ "X-Shopify-Access-Token": adminAccessToken
5033
+ },
5034
+ body: {
5035
+ query,
5036
+ ...variables && { variables }
4887
5037
  }
4888
- );
5038
+ });
5039
+ if (errors) {
5040
+ throw new Error("Shopify GraphQL error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
5041
+ }
5042
+ ;
5043
+ return data2;
4889
5044
  };
4890
-
4891
- // lib/connections/manifests/shopify.js
4892
- var toLine = ({
4893
- price,
4894
- product_id: productId,
4895
- quantity,
4896
- title,
4897
- variant_id: variantId,
4898
- variant_title: variantTitle
4899
- }) => ({
4900
- price: parseFloat(price) || 0,
4901
- productId: productId ? "gid://shopify/Product/" + productId : null,
4902
- quantity: quantity || 1,
4903
- title: title || null,
4904
- variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
4905
- variantTitle: variantTitle || null
4906
- });
4907
- var attributeLineItems = (lineItems = []) => lineItems.reduce(
4908
- (acc, item) => {
4909
- const attrs = (item.properties || []).reduce(
4910
- (map, { name, value }) => {
4911
- map[name] = value;
4912
- return map;
4913
- },
4914
- {}
4915
- );
4916
- if (!attrs["_drwbrdg_ca"]) return acc;
4917
- if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
4918
- const line = toLine(item);
4919
- acc.attributedGross += line.price * line.quantity;
4920
- acc.attributedLines.push(line);
4921
- return acc;
4922
- },
4923
- { attrMap: {}, attributedGross: 0, attributedLines: [] }
4924
- );
4925
- var generateDiscountCode = (0, import_nanoid.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4926
- var blockedReason = (discount) => {
5045
+ var DISCOUNT_FIELDS = `
5046
+ title
5047
+ status
5048
+ startsAt
5049
+ endsAt
5050
+ usageLimit
5051
+ asyncUsageCount
5052
+ appliesOncePerCustomer
5053
+ context { __typename }
5054
+ `;
5055
+ var discountsQuery = (search, cursor, limit) => `{
5056
+ codeDiscountNodes(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}) {
5057
+ edges {
5058
+ node {
5059
+ id
5060
+ codeDiscount {
5061
+ ... on DiscountCodeBasic { ${DISCOUNT_FIELDS} }
5062
+ ... on DiscountCodeBxgy { ${DISCOUNT_FIELDS} }
5063
+ ... on DiscountCodeFreeShipping { ${DISCOUNT_FIELDS} }
5064
+ ... on DiscountCodeApp { ${DISCOUNT_FIELDS} }
5065
+ }
5066
+ }
5067
+ }
5068
+ pageInfo {
5069
+ endCursor
5070
+ hasNextPage
5071
+ hasPreviousPage
5072
+ startCursor
5073
+ }
5074
+ }
5075
+ }`;
5076
+ var getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
5077
+ const data2 = await adminFetch({
5078
+ fetcher,
5079
+ adminAccessToken,
5080
+ domain,
5081
+ query: discountsQuery(search, cursor, limit)
5082
+ });
5083
+ return data2 == null ? void 0 : data2.codeDiscountNodes;
5084
+ };
5085
+ var discountGid = (id) => String(id).startsWith("gid://") ? String(id) : "gid://shopify/DiscountCodeNode/" + id;
5086
+ var createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code: code2 }) => {
5087
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5088
+ const added = await adminFetch({
5089
+ fetcher,
5090
+ adminAccessToken,
5091
+ domain,
5092
+ query: `
5093
+ mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
5094
+ discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
5095
+ bulkCreation {
5096
+ id
5097
+ }
5098
+ userErrors {
5099
+ field
5100
+ message
5101
+ }
5102
+ }
5103
+ }
5104
+ `,
5105
+ variables: {
5106
+ codes: [{ code: code2 }],
5107
+ discountId: discountGid(discountId)
5108
+ }
5109
+ });
5110
+ const addErrors = (_a = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _a.userErrors;
5111
+ if (addErrors == null ? void 0 : addErrors.length) {
5112
+ throw new Error("Shopify discount code create failed: " + addErrors[0].message);
5113
+ }
5114
+ ;
5115
+ const bulkCreationId = (_c = (_b = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _b.bulkCreation) == null ? void 0 : _c.id;
5116
+ if (!bulkCreationId) {
5117
+ throw new Error("Shopify discount code create failed: no bulk creation id returned");
5118
+ }
5119
+ ;
5120
+ for (let attempt = 0; attempt < 15; attempt++) {
5121
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5122
+ const polled = await adminFetch({
5123
+ fetcher,
5124
+ adminAccessToken,
5125
+ domain,
5126
+ query: `
5127
+ query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
5128
+ discountRedeemCodeBulkCreation(id: $id) {
5129
+ done
5130
+ codes(first: 1) {
5131
+ nodes {
5132
+ code
5133
+ discountRedeemCode {
5134
+ id
5135
+ }
5136
+ errors {
5137
+ message
5138
+ }
5139
+ }
5140
+ }
5141
+ }
5142
+ }
5143
+ `,
5144
+ variables: {
5145
+ id: bulkCreationId
5146
+ }
5147
+ });
5148
+ const creation = polled == null ? void 0 : polled.discountRedeemCodeBulkCreation;
5149
+ if (!(creation == null ? void 0 : creation.done)) continue;
5150
+ const node = (_e = (_d = creation.codes) == null ? void 0 : _d.nodes) == null ? void 0 : _e[0];
5151
+ if (!((_f = node == null ? void 0 : node.discountRedeemCode) == null ? void 0 : _f.id)) {
5152
+ 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"));
5153
+ }
5154
+ ;
5155
+ return {
5156
+ code: node.code,
5157
+ // Numeric tail keeps the stored shopifyDiscountId in the shape the
5158
+ // legacy REST endpoint returned — it feeds a merchant-visible
5159
+ // workflow template variable, so no gid:// leakage.
5160
+ id: node.discountRedeemCode.id.split("/").pop()
5161
+ };
5162
+ }
5163
+ ;
5164
+ throw new Error("Shopify discount code create timed out waiting for bulk creation " + bulkCreationId);
5165
+ };
5166
+ var getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
5167
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5168
+ const lookupQuery = `
5169
+ query customerByEmail( $query : String! ) {
5170
+ customers( first : 1, query : $query ) {
5171
+ edges {
5172
+ node {
5173
+ id
5174
+ }
5175
+ }
5176
+ }
5177
+ }
5178
+ `;
5179
+ const data2 = await adminFetch({
5180
+ fetcher,
5181
+ adminAccessToken,
5182
+ domain,
5183
+ query: lookupQuery,
5184
+ variables: { query: "email:" + email }
5185
+ });
5186
+ 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;
5187
+ if (existing) return existing;
5188
+ const input = { email };
5189
+ if (firstName) input.firstName = firstName;
5190
+ if (lastName) input.lastName = lastName;
5191
+ if (source) {
5192
+ input.metafields = [{
5193
+ namespace: "drwbrdg",
5194
+ key: "source",
5195
+ type: "single_line_text_field",
5196
+ value: source
5197
+ }];
5198
+ }
5199
+ ;
5200
+ const result = await adminFetch({
5201
+ fetcher,
5202
+ adminAccessToken,
5203
+ domain,
5204
+ query: `
5205
+ mutation customerCreate( $input : CustomerInput! ) {
5206
+ customerCreate( input : $input ) {
5207
+ customer {
5208
+ id
5209
+ }
5210
+ userErrors {
5211
+ field
5212
+ message
5213
+ }
5214
+ }
5215
+ }
5216
+ `,
5217
+ variables: { input }
5218
+ });
5219
+ const userErrors = (_d = result == null ? void 0 : result.customerCreate) == null ? void 0 : _d.userErrors;
5220
+ if (userErrors == null ? void 0 : userErrors.length) {
5221
+ const emailTaken = userErrors.some((e) => {
5222
+ var _a2;
5223
+ return (_a2 = e.message) == null ? void 0 : _a2.includes("Email has already been taken");
5224
+ });
5225
+ if (emailTaken) {
5226
+ const retry = await adminFetch({
5227
+ fetcher,
5228
+ adminAccessToken,
5229
+ domain,
5230
+ query: lookupQuery,
5231
+ variables: { query: "email:" + email }
5232
+ });
5233
+ 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;
5234
+ if (found) return found;
5235
+ }
5236
+ ;
5237
+ throw new Error("Shopify customer create failed: " + userErrors[0].message);
5238
+ }
5239
+ ;
5240
+ return (_h = result == null ? void 0 : result.customerCreate) == null ? void 0 : _h.customer;
5241
+ };
5242
+ var getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
5243
+ var _a, _b;
5244
+ const data2 = await adminFetch({
5245
+ fetcher,
5246
+ adminAccessToken,
5247
+ domain,
5248
+ query: `{
5249
+ currentAppInstallation {
5250
+ activeSubscriptions {
5251
+ id
5252
+ name
5253
+ status
5254
+ test
5255
+ lineItems {
5256
+ id
5257
+ plan {
5258
+ pricingDetails {
5259
+ __typename
5260
+ }
5261
+ }
5262
+ }
5263
+ }
5264
+ }
5265
+ shop {
5266
+ myshopifyDomain
5267
+ }
5268
+ }`
5269
+ });
5270
+ return {
5271
+ shopDomain: ((_a = data2 == null ? void 0 : data2.shop) == null ? void 0 : _a.myshopifyDomain) || null,
5272
+ subscriptions: (((_b = data2 == null ? void 0 : data2.currentAppInstallation) == null ? void 0 : _b.activeSubscriptions) || []).map((subscription) => {
5273
+ var _a2;
5274
+ return {
5275
+ ...subscription,
5276
+ metered: ((subscription == null ? void 0 : subscription.lineItems) || []).some(
5277
+ (item) => {
5278
+ var _a3, _b2;
5279
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5280
+ }
5281
+ ),
5282
+ // The usage line's own id — the address appUsageRecordCreate charges
5283
+ // against. Null when the approval carries no usage component.
5284
+ usageLineItemId: ((_a2 = ((subscription == null ? void 0 : subscription.lineItems) || []).find(
5285
+ (item) => {
5286
+ var _a3, _b2;
5287
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5288
+ }
5289
+ )) == null ? void 0 : _a2.id) || null
5290
+ };
5291
+ })
5292
+ };
5293
+ };
5294
+ var createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = "USD", domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
5295
+ if (!name || !returnUrl || !terms || !(Number(cappedAmount) > 0)) {
5296
+ throw new Error("createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount");
5297
+ }
5298
+ ;
5299
+ const data2 = await adminFetch({
5300
+ fetcher,
5301
+ adminAccessToken,
5302
+ domain,
5303
+ query: `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
5304
+ appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
5305
+ appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
5306
+ confirmationUrl
5307
+ userErrors { field message }
5308
+ }
5309
+ }`,
5310
+ variables: {
5311
+ lineItems: [
5312
+ {
5313
+ plan: {
5314
+ appRecurringPricingDetails: {
5315
+ interval: "EVERY_30_DAYS",
5316
+ price: { amount: Number(recurringPrice), currencyCode }
5317
+ }
5318
+ }
5319
+ },
5320
+ {
5321
+ plan: {
5322
+ appUsagePricingDetails: {
5323
+ cappedAmount: { amount: Number(cappedAmount), currencyCode },
5324
+ terms
5325
+ }
5326
+ }
5327
+ }
5328
+ ],
5329
+ name,
5330
+ returnUrl,
5331
+ test
5332
+ }
5333
+ });
5334
+ const payload = data2 == null ? void 0 : data2.appSubscriptionCreate;
5335
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5336
+ if (userErrors.length) {
5337
+ throw new Error("Shopify subscription create failed: " + userErrors.map((entry) => entry.message).join("; "));
5338
+ }
5339
+ ;
5340
+ if (!(payload == null ? void 0 : payload.confirmationUrl)) {
5341
+ throw new Error("Shopify subscription create failed: no confirmation url returned");
5342
+ }
5343
+ ;
5344
+ return {
5345
+ confirmationUrl: payload.confirmationUrl,
5346
+ subscription: payload.appSubscription
5347
+ };
5348
+ };
5349
+ var createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = "USD", description, domain, idempotencyKey, subscriptionLineItemId }) => {
4927
5350
  var _a;
4928
- if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4929
- const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4930
- if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4931
- 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.";
5351
+ if (!subscriptionLineItemId || !description || !(Number(amount) > 0)) {
5352
+ throw new Error("createUsageRecord requires subscriptionLineItemId, description, and a positive amount");
4932
5353
  }
4933
5354
  ;
4934
- if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4935
- return "This discount has reached its total usage limit.";
5355
+ const data2 = await adminFetch({
5356
+ fetcher,
5357
+ adminAccessToken,
5358
+ domain,
5359
+ query: `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
5360
+ appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
5361
+ appUsageRecord { id }
5362
+ userErrors { field message }
5363
+ }
5364
+ }`,
5365
+ variables: {
5366
+ description,
5367
+ idempotencyKey: idempotencyKey ? String(idempotencyKey) : null,
5368
+ price: { amount: Number(amount), currencyCode },
5369
+ subscriptionLineItemId
5370
+ }
5371
+ });
5372
+ const payload = data2 == null ? void 0 : data2.appUsageRecordCreate;
5373
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5374
+ if (userErrors.length || !((_a = payload == null ? void 0 : payload.appUsageRecord) == null ? void 0 : _a.id)) {
5375
+ throw new Error("Shopify usage record failed: " + (userErrors.map((entry) => entry.message).join("; ") || "no record returned"));
4936
5376
  }
4937
5377
  ;
4938
- return null;
5378
+ return { id: payload.appUsageRecord.id };
4939
5379
  };
4940
- var discountWarning = (discount) => {
4941
- if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4942
- return "This discount hasn't started yet, so codes issued before it does won't work until then.";
5380
+ var getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
5381
+ var _a, _b;
5382
+ const data2 = await adminFetch({
5383
+ fetcher,
5384
+ adminAccessToken,
5385
+ domain,
5386
+ query: `{
5387
+ product( id : "${productId}" ) {
5388
+ variants( first : 100 ) {
5389
+ edges {
5390
+ node {
5391
+ id
5392
+ inventoryQuantity
5393
+ }
5394
+ }
5395
+ }
5396
+ }
5397
+ }`
5398
+ });
5399
+ const edges = ((_b = (_a = data2 == null ? void 0 : data2.product) == null ? void 0 : _a.variants) == null ? void 0 : _b.edges) || [];
5400
+ return edges.reduce((accumulator, edge) => {
5401
+ var _a2, _b2;
5402
+ const id = (_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id;
5403
+ const quantity = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.inventoryQuantity;
5404
+ if (id) {
5405
+ accumulator[id] = Number.isFinite(quantity) ? quantity : 0;
5406
+ }
5407
+ ;
5408
+ return accumulator;
5409
+ }, {});
5410
+ };
5411
+ var getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
5412
+ var _a;
5413
+ const data2 = await adminFetch({
5414
+ fetcher,
5415
+ adminAccessToken,
5416
+ domain,
5417
+ query: `
5418
+ query ProductUpdatedAt($id: ID!) {
5419
+ product(id: $id) {
5420
+ updatedAt
5421
+ }
5422
+ }
5423
+ `,
5424
+ variables: { id: productId }
5425
+ });
5426
+ return ((_a = data2 == null ? void 0 : data2.product) == null ? void 0 : _a.updatedAt) || null;
5427
+ };
5428
+ var NOT_ON_CHANNEL = "not available to the channel";
5429
+ var OUTDATED_FEEDBACK = "later version";
5430
+ var sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
5431
+ var _a, _b, _c;
5432
+ const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
5433
+ if (!productUpdatedAt) return null;
5434
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5435
+ const submit = async (version) => {
5436
+ const data2 = await adminFetch({
5437
+ fetcher,
5438
+ adminAccessToken,
5439
+ domain,
5440
+ query: `
5441
+ mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
5442
+ bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
5443
+ feedback {
5444
+ productId
5445
+ state
5446
+ }
5447
+ userErrors {
5448
+ field
5449
+ message
5450
+ }
5451
+ }
5452
+ }
5453
+ `,
5454
+ variables: {
5455
+ feedbackInput: [
5456
+ {
5457
+ productId,
5458
+ state,
5459
+ feedbackGeneratedAt: now,
5460
+ productUpdatedAt: version,
5461
+ messages
5462
+ }
5463
+ ]
5464
+ }
5465
+ });
5466
+ return data2 == null ? void 0 : data2.bulkProductResourceFeedbackCreate;
5467
+ };
5468
+ let result = await submit(productUpdatedAt);
5469
+ if ((((_b = (_a = result == null ? void 0 : result.userErrors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) || "").toLowerCase().includes(OUTDATED_FEEDBACK)) {
5470
+ result = await submit(now);
4943
5471
  }
4944
5472
  ;
4945
- if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4946
- return null;
5473
+ const feedbackErrors = result == null ? void 0 : result.userErrors;
5474
+ if (feedbackErrors == null ? void 0 : feedbackErrors.length) {
5475
+ const message = feedbackErrors[0].message || "";
5476
+ if (message.toLowerCase().includes(NOT_ON_CHANNEL)) return null;
5477
+ throw new Error("Shopify product feedback failed: " + message);
5478
+ }
5479
+ ;
5480
+ return ((_c = result == null ? void 0 : result.feedback) == null ? void 0 : _c[0]) || null;
4947
5481
  };
4948
- var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
4949
- var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
4950
- var OAUTH_ERROR_SOURCE = "oauth";
4951
- var BILLING_ERROR_SOURCE = "billing";
4952
- var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
4953
- var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
4954
- var inbound = {
4955
- headers: {
4956
- event: "x-shopify-topic",
4957
- id: "x-shopify-webhook-id",
4958
- shop: "x-shopify-shop-domain",
4959
- signature: "x-shopify-hmac-sha256"
4960
- },
4961
- signature: {
4962
- algorithm: "sha256",
4963
- encoding: "base64",
4964
- secret: "SHOPIFY_API_SECRET"
5482
+
5483
+ // lib/shopify/billing.js
5484
+ var billing_exports = {};
5485
+ __export(billing_exports, {
5486
+ sendAppEvent: () => sendAppEvent
5487
+ });
5488
+ var APP_API = "https://api.shopify.com";
5489
+ var APP_API_VERSION = process.env.SHOPIFY_APP_API_VERSION || SHOPIFY_APP_API_VERSION;
5490
+ var cachedTokens = {};
5491
+ var fetchAppToken = async ({ clientId, clientSecret, fetcher }) => {
5492
+ const data2 = await request({
5493
+ fetcher,
5494
+ method: "POST",
5495
+ url: APP_API + "/auth/access_token",
5496
+ body: {
5497
+ client_id: clientId,
5498
+ client_secret: clientSecret,
5499
+ grant_type: "client_credentials"
5500
+ }
5501
+ });
5502
+ if (!(data2 == null ? void 0 : data2.access_token)) {
5503
+ throw new Error("Shopify app token request failed: no access token returned");
4965
5504
  }
5505
+ ;
5506
+ const ttl = data2.expires_in ? data2.expires_in * 1e3 : 60 * 60 * 1e3;
5507
+ cachedTokens[clientId] = {
5508
+ expiresAt: Date.now() + ttl - 60 * 1e3,
5509
+ token: data2.access_token
5510
+ };
5511
+ return data2.access_token;
4966
5512
  };
4967
- var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
5513
+ var getAppToken = async ({ clientId, clientSecret, fetcher }) => {
5514
+ const cached = cachedTokens[clientId];
5515
+ if (cached && cached.expiresAt > Date.now()) {
5516
+ return cached.token;
5517
+ }
5518
+ ;
5519
+ return fetchAppToken({ clientId, clientSecret, fetcher });
5520
+ };
5521
+ var toShopGid = (shopId) => String(shopId).startsWith("gid://") ? String(shopId) : "gid://shopify/Shop/" + shopId;
5522
+ var sendAppEvent = async ({ fetcher, clientId, clientSecret, eventHandle, idempotencyKey, reference, revision, shopId, timestamp, value }) => {
5523
+ if (!shopId || !eventHandle || !(Number(value) > 0)) {
5524
+ throw new Error("sendAppEvent requires shopId, eventHandle, and a positive value");
5525
+ }
5526
+ ;
5527
+ if (!clientId || !clientSecret) {
5528
+ throw new Error("sendAppEvent requires clientId and clientSecret");
5529
+ }
5530
+ ;
5531
+ const suffix = revision ? ".r" + revision : "";
5532
+ const key = idempotencyKey && String(idempotencyKey).slice(0, Math.max(0, 64 - suffix.length)) + suffix;
5533
+ const body = {
5534
+ attributes: {
5535
+ value: Number(value),
5536
+ ...reference && {
5537
+ reference: String(reference).slice(0, 128)
5538
+ }
5539
+ },
5540
+ event_handle: eventHandle,
5541
+ shop_id: toShopGid(shopId),
5542
+ timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString(),
5543
+ ...key && {
5544
+ idempotency_key: key
5545
+ }
5546
+ };
5547
+ const send2 = async (token) => request({
5548
+ method: "POST",
5549
+ url: APP_API + "/app/" + APP_API_VERSION + "/events",
5550
+ headers: {
5551
+ "Authorization": "Bearer " + token
5552
+ },
5553
+ body
5554
+ });
5555
+ try {
5556
+ return await send2(await getAppToken({ clientId, clientSecret, fetcher }));
5557
+ } catch (error) {
5558
+ if ((error == null ? void 0 : error.status) === 401) {
5559
+ delete cachedTokens[clientId];
5560
+ return send2(await fetchAppToken({ clientId, clientSecret, fetcher }));
5561
+ }
5562
+ ;
5563
+ throw error;
5564
+ }
5565
+ };
5566
+
5567
+ // lib/shopify/oauth.js
5568
+ var oauth_exports = {};
5569
+ __export(oauth_exports, {
5570
+ SHOPIFY_REQUIRED_SCOPES: () => SHOPIFY_REQUIRED_SCOPES,
5571
+ createStorefrontToken: () => createStorefrontToken,
5572
+ getAdminToken: () => getAdminToken,
5573
+ getShop: () => getShop,
5574
+ missingScopes: () => missingScopes2,
5575
+ ping: () => ping,
5576
+ refreshAdminToken: () => refreshAdminToken,
5577
+ resolveConnectionSettings: () => resolveConnectionSettings
5578
+ });
5579
+ var required = (name, value) => {
5580
+ if (!value) {
5581
+ throw new Error("Shopify " + name + " is required");
5582
+ }
5583
+ ;
5584
+ return value;
5585
+ };
5586
+ var missingScopes2 = (granted) => {
5587
+ const grantedSet = new Set(
5588
+ (Array.isArray(granted) ? granted : String(granted || "").split(",")).map((scope) => scope.trim()).filter(Boolean)
5589
+ );
5590
+ const satisfied = (scope) => grantedSet.has(scope) || grantedSet.has(scope.replace(/(^|_)read_/, "$1write_"));
5591
+ return SHOPIFY_REQUIRED_SCOPES.filter((scope) => !satisfied(scope));
5592
+ };
5593
+ var ping = async ({ fetcher, adminAccessToken, domain }) => {
5594
+ var _a;
5595
+ const response = await fetch(
5596
+ `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`,
5597
+ {
5598
+ body: JSON.stringify({ query: "{ shop { name } }" }),
5599
+ headers: {
5600
+ "Content-Type": "application/json",
5601
+ "X-Shopify-Access-Token": adminAccessToken
5602
+ },
5603
+ method: "POST"
5604
+ }
5605
+ );
5606
+ if (!response.ok) {
5607
+ const text = await response.text().catch(() => "");
5608
+ const error = new Error(response.status + ": " + text);
5609
+ error.status = response.status;
5610
+ throw error;
5611
+ }
5612
+ ;
5613
+ const { errors } = await response.json().catch(() => ({}));
5614
+ if (errors == null ? void 0 : errors.length) {
5615
+ throw new Error("GraphQL: " + (((_a = errors[0]) == null ? void 0 : _a.message) || "unknown error"));
5616
+ }
5617
+ ;
5618
+ };
5619
+ var OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
5620
+ var parseOAuthErrorCode = (text) => {
5621
+ var _a;
5622
+ try {
5623
+ const parsed = JSON.parse(text);
5624
+ if (parsed == null ? void 0 : parsed.error) return parsed.error;
5625
+ } catch {
5626
+ }
5627
+ ;
5628
+ return ((_a = text.match(OAUTH_ERROR_TITLE_PATTERN)) == null ? void 0 : _a[1]) || null;
5629
+ };
5630
+ var shopifyOAuthFetch = async (url, body) => {
5631
+ const response = await fetch(url, {
5632
+ method: "POST",
5633
+ headers: {
5634
+ "Content-Type": "application/json"
5635
+ },
5636
+ body: JSON.stringify(body)
5637
+ });
5638
+ if (!response.ok) {
5639
+ const text = await response.text().catch(() => "");
5640
+ const error = new Error(response.status + ": " + text);
5641
+ error.status = response.status;
5642
+ error.code = parseOAuthErrorCode(text);
5643
+ throw error;
5644
+ }
5645
+ ;
5646
+ return response.json();
5647
+ };
5648
+ var getShop = async ({ fetcher, adminAccessToken, shop }) => {
5649
+ const data2 = await adminFetch({
5650
+ fetcher,
5651
+ adminAccessToken,
5652
+ domain: shop,
5653
+ query: `{
5654
+ shop {
5655
+ currencyCode
5656
+ id
5657
+ myshopifyDomain
5658
+ name
5659
+ }
5660
+ }`
5661
+ });
5662
+ const shopData = data2 == null ? void 0 : data2.shop;
5663
+ if (!(shopData == null ? void 0 : shopData.id)) {
5664
+ throw new Error("Shopify shop lookup failed: no shop returned");
5665
+ }
5666
+ ;
5667
+ return {
5668
+ currency: shopData.currencyCode || null,
5669
+ id: shopData.id.split("/").pop(),
5670
+ myshopifyDomain: shopData.myshopifyDomain,
5671
+ name: shopData.name
5672
+ };
5673
+ };
5674
+ var createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
5675
+ var _a, _b, _c, _d, _e;
5676
+ try {
5677
+ const listed = await adminFetch({
5678
+ fetcher,
5679
+ adminAccessToken,
5680
+ domain: shop,
5681
+ query: `{
5682
+ shop {
5683
+ storefrontAccessTokens(first: 100) {
5684
+ edges {
5685
+ node {
5686
+ id
5687
+ title
5688
+ }
5689
+ }
5690
+ }
5691
+ }
5692
+ }`
5693
+ });
5694
+ 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);
5695
+ const orphaned = existing.filter((token) => (token == null ? void 0 : token.title) === "Drawbridge");
5696
+ await Promise.allSettled(
5697
+ orphaned.map((token) => adminFetch({
5698
+ adminAccessToken,
5699
+ domain: shop,
5700
+ query: `
5701
+ mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
5702
+ storefrontAccessTokenDelete(input: $input) {
5703
+ deletedStorefrontAccessTokenId
5704
+ userErrors {
5705
+ field
5706
+ message
5707
+ }
5708
+ }
5709
+ }
5710
+ `,
5711
+ variables: {
5712
+ input: {
5713
+ id: token.id
5714
+ }
5715
+ }
5716
+ }))
5717
+ );
5718
+ } catch {
5719
+ }
5720
+ const created = await adminFetch({
5721
+ fetcher,
5722
+ adminAccessToken,
5723
+ domain: shop,
5724
+ query: `
5725
+ mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
5726
+ storefrontAccessTokenCreate(input: $input) {
5727
+ storefrontAccessToken {
5728
+ accessToken
5729
+ }
5730
+ userErrors {
5731
+ field
5732
+ message
5733
+ }
5734
+ }
5735
+ }
5736
+ `,
5737
+ variables: {
5738
+ input: {
5739
+ title: "Drawbridge"
5740
+ }
5741
+ }
5742
+ });
5743
+ const createErrors = (_c = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _c.userErrors;
5744
+ if (createErrors == null ? void 0 : createErrors.length) {
5745
+ throw new Error("Shopify storefront token create failed: " + createErrors[0].message);
5746
+ }
5747
+ ;
5748
+ return ((_e = (_d = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _d.storefrontAccessToken) == null ? void 0 : _e.accessToken) || null;
5749
+ };
5750
+ var resolveTokenContext = async ({ connection: connection2, controller }) => {
5751
+ const decrypted = (connection2 == null ? void 0 : connection2.settings) ? decrypt(connection2.settings) : {};
5752
+ if ((decrypted == null ? void 0 : decrypted.ref) !== "shop") {
5753
+ return {
5754
+ raw: (connection2 == null ? void 0 : connection2.settings) || null,
5755
+ session: null,
5756
+ settings: decrypted,
5757
+ shop: null,
5758
+ target: "connection"
5759
+ };
5760
+ }
5761
+ ;
5762
+ const shop = decrypted.shop;
5763
+ if (!controller || !shop) {
5764
+ return { raw: null, session: {}, settings: {}, shop, target: "shop" };
5765
+ }
5766
+ ;
5767
+ const record = await controller.get({ collection: "shop", query: { shop } });
5768
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : {};
5769
+ const storefront = (record == null ? void 0 : record.storefront) ? decrypt(record.storefront) : null;
5770
+ return {
5771
+ raw: (record == null ? void 0 : record.settings) || null,
5772
+ session,
5773
+ settings: {
5774
+ adminAccessToken: session.accessToken || null,
5775
+ refreshToken: session.refreshToken || null,
5776
+ refreshTokenExpiresAt: session.refreshTokenExpires || null,
5777
+ storefrontAccessToken: (storefront == null ? void 0 : storefront.token) || null,
5778
+ tokenExpiresAt: session.expires || null
5779
+ },
5780
+ shop,
5781
+ target: "shop"
5782
+ };
5783
+ };
5784
+ var resolveConnectionSettings = async ({ fetcher, connection: connection2, controller }) => {
5785
+ const { settings } = await resolveTokenContext({ connection: connection2, controller });
5786
+ return settings;
5787
+ };
5788
+ var refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection: connection2, controller }) => {
5789
+ var _a;
5790
+ const { raw, session, settings, shop, target } = await resolveTokenContext({ connection: connection2, controller });
5791
+ const domain = (connection2 == null ? void 0 : connection2.shop) || ((_a = settings.source) == null ? void 0 : _a.domain) || settings.domain || shop;
5792
+ const { refreshToken } = settings;
5793
+ const data2 = await shopifyOAuthFetch(
5794
+ `https://${domain}/admin/oauth/access_token`,
5795
+ {
5796
+ grant_type: "refresh_token",
5797
+ // Throw before the grant: rotation RETIRES the old pair the moment it
5798
+ // returns, so a credential-less call that Shopify rejects opaquely is
5799
+ // the one failure mode worth being loud about.
5800
+ client_id: required("clientId", clientId),
5801
+ client_secret: required("clientSecret", clientSecret),
5802
+ refresh_token: refreshToken
5803
+ }
5804
+ );
5805
+ const adminAccessToken = data2.access_token;
5806
+ const newRefreshToken = data2.refresh_token;
5807
+ const expiresIn = data2.expires_in;
5808
+ const tokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : null;
5809
+ const refreshTokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS);
5810
+ let liveToken = adminAccessToken;
5811
+ if (target === "shop") {
5812
+ const written = await controller.update({
5813
+ collection: "shop",
5814
+ data: {
5815
+ $set: {
5816
+ settings: encrypt({
5817
+ ...session,
5818
+ accessToken: adminAccessToken,
5819
+ expires: tokenExpiresAt,
5820
+ refreshToken: newRefreshToken,
5821
+ refreshTokenExpires: refreshTokenExpiresAt
5822
+ })
5823
+ }
5824
+ },
5825
+ query: {
5826
+ shop,
5827
+ ...raw && { settings: raw }
5828
+ }
5829
+ });
5830
+ if (!written) {
5831
+ const current = await controller.get({ collection: "shop", query: { shop } });
5832
+ if (!(current == null ? void 0 : current.settings)) {
5833
+ throw new Error("Shopify token refresh write-back failed: shop record missing for " + shop);
5834
+ }
5835
+ ;
5836
+ const currentSession = decrypt(current.settings);
5837
+ if ((currentSession == null ? void 0 : currentSession.accessToken) && currentSession.accessToken !== settings.adminAccessToken) {
5838
+ liveToken = currentSession.accessToken;
5839
+ } else {
5840
+ throw new Error("Shopify token refresh write-back failed for " + shop);
5841
+ }
5842
+ ;
5843
+ }
5844
+ ;
5845
+ } else {
5846
+ const written = await controller.update({
5847
+ collection: "connection",
5848
+ data: {
5849
+ $set: {
5850
+ settings: encrypt({
5851
+ ...settings,
5852
+ adminAccessToken,
5853
+ refreshToken: newRefreshToken,
5854
+ refreshTokenExpiresAt,
5855
+ tokenExpiresAt
5856
+ })
5857
+ }
5858
+ },
5859
+ query: {
5860
+ id: connection2.id,
5861
+ ...raw && { settings: raw }
5862
+ }
5863
+ });
5864
+ if (!written) {
5865
+ const current = await controller.get({ collection: "connection", query: { id: connection2.id } });
5866
+ const currentSettings = (current == null ? void 0 : current.settings) ? decrypt(current.settings) : null;
5867
+ if ((currentSettings == null ? void 0 : currentSettings.adminAccessToken) && currentSettings.adminAccessToken !== settings.adminAccessToken) {
5868
+ liveToken = currentSettings.adminAccessToken;
5869
+ } else {
5870
+ throw new Error("Shopify token refresh write-back failed for connection " + (connection2 == null ? void 0 : connection2.id));
5871
+ }
5872
+ ;
5873
+ }
5874
+ ;
5875
+ }
5876
+ ;
5877
+ await ping({ adminAccessToken: liveToken, domain });
5878
+ return liveToken;
5879
+ };
5880
+ var getAdminToken = async ({ fetcher, clientId, clientSecret, connection: connection2, controller }) => {
5881
+ required("clientId", clientId);
5882
+ required("clientSecret", clientSecret);
5883
+ const settings = await resolveConnectionSettings({ connection: connection2, controller });
5884
+ const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
5885
+ if (!adminAccessToken) {
5886
+ return null;
5887
+ }
5888
+ ;
5889
+ if (!refreshToken) {
5890
+ return adminAccessToken;
5891
+ }
5892
+ ;
5893
+ const needsRefresh = !tokenExpiresAt || new Date(tokenExpiresAt) < new Date(Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS);
5894
+ if (needsRefresh) {
5895
+ return refreshAdminToken({ clientId, clientSecret, connection: connection2, controller });
5896
+ }
5897
+ ;
5898
+ return adminAccessToken;
5899
+ };
5900
+
5901
+ // lib/shopify/partner.js
5902
+ var partner_exports = {};
5903
+ __export(partner_exports, {
5904
+ getUsageChargeEvents: () => getUsageChargeEvents
5905
+ });
5906
+ var PARTNER_API = "https://partners.shopify.com";
5907
+ var getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
5908
+ var _a, _b, _c;
5909
+ if (!partnerToken || !organizationId || !appId) {
5910
+ throw new Error("getUsageChargeEvents requires partnerToken, organizationId, and appId");
5911
+ }
5912
+ ;
5913
+ const data2 = await request({
5914
+ fetcher,
5915
+ method: "POST",
5916
+ url: PARTNER_API + "/" + organizationId + "/api/" + PARTNER_API_VERSION + "/graphql.json",
5917
+ headers: {
5918
+ "X-Shopify-Access-Token": partnerToken
5919
+ },
5920
+ body: {
5921
+ query: `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
5922
+ events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
5923
+ edges {
5924
+ node {
5925
+ id
5926
+ occurredAt
5927
+ eventType
5928
+ shop { id myshopifyDomain }
5929
+ ... on Charge {
5930
+ chargeId
5931
+ chargeType
5932
+ amount { amount currencyCode }
5933
+ usageQuantity
5934
+ planHandle
5935
+ description
5936
+ }
5937
+ }
5938
+ }
5939
+ pageInfo { hasNextPage endCursor }
5940
+ }
5941
+ }`,
5942
+ variables: {
5943
+ filter: {
5944
+ eventTypes: ["CHARGE_USAGE"],
5945
+ subjectId: String(appId),
5946
+ ...occurredAtMin && { occurredAtMin },
5947
+ ...shopId && { shopId: String(shopId) }
5948
+ },
5949
+ first
5950
+ }
5951
+ }
5952
+ });
5953
+ if ((_a = data2 == null ? void 0 : data2.errors) == null ? void 0 : _a.length) {
5954
+ throw new Error("Shopify partner events query failed: " + data2.errors.map((entry) => entry.message).join("; "));
5955
+ }
5956
+ ;
5957
+ return (((_c = (_b = data2 == null ? void 0 : data2.data) == null ? void 0 : _b.events) == null ? void 0 : _c.edges) || []).map((edge) => {
5958
+ var _a2, _b2, _c2, _d, _e, _f, _g, _h, _i, _j;
5959
+ return {
5960
+ amount: ((_a2 = edge.node) == null ? void 0 : _a2.amount) || null,
5961
+ chargeId: ((_b2 = edge.node) == null ? void 0 : _b2.chargeId) || null,
5962
+ // USAGE vs the recurring plan charge an approval emits — the
5963
+ // disambiguator callers filter on.
5964
+ chargeType: ((_c2 = edge.node) == null ? void 0 : _c2.chargeType) || null,
5965
+ description: ((_d = edge.node) == null ? void 0 : _d.description) || null,
5966
+ eventType: ((_e = edge.node) == null ? void 0 : _e.eventType) || null,
5967
+ id: ((_f = edge.node) == null ? void 0 : _f.id) || null,
5968
+ occurredAt: ((_g = edge.node) == null ? void 0 : _g.occurredAt) || null,
5969
+ planHandle: ((_h = edge.node) == null ? void 0 : _h.planHandle) || null,
5970
+ shop: ((_i = edge.node) == null ? void 0 : _i.shop) || null,
5971
+ usageQuantity: ((_j = edge.node) == null ? void 0 : _j.usageQuantity) ?? null
5972
+ };
5973
+ });
5974
+ };
5975
+
5976
+ // lib/shopify/storefront.js
5977
+ var storefront_exports = {};
5978
+ __export(storefront_exports, {
5979
+ cartCreate: () => cartCreate,
5980
+ cartGet: () => cartGet,
5981
+ cartLinesAdd: () => cartLinesAdd,
5982
+ cartLinesRemove: () => cartLinesRemove,
5983
+ cartLinesUpdate: () => cartLinesUpdate,
5984
+ createCheckoutToken: () => createCheckoutToken,
5985
+ getProduct: () => getProduct,
5986
+ getProductVariantsPage: () => getProductVariantsPage,
5987
+ getProducts: () => getProducts,
5988
+ probeVariantsOutOfStock: () => probeVariantsOutOfStock
5989
+ });
5990
+ var SORT_KEY_MAP = {
5991
+ bestSelling: "BEST_SELLING",
5992
+ createdAt: "CREATED_AT",
5993
+ id: "ID",
5994
+ price: "PRICE",
5995
+ productType: "PRODUCT_TYPE",
5996
+ title: "TITLE",
5997
+ updatedAt: "UPDATED_AT",
5998
+ vendor: "VENDOR"
5999
+ };
6000
+ var resolveProductSort = (sort) => {
6001
+ const entry = sort && Object.entries(sort)[0];
6002
+ if (!entry) return null;
6003
+ const [field2, direction] = entry;
6004
+ const sortKey = SORT_KEY_MAP[field2];
6005
+ if (!sortKey) return null;
6006
+ return {
6007
+ sortKey,
6008
+ reverse: Number(direction) < 0
6009
+ };
6010
+ };
6011
+ var storefrontUrl = (domain) => `https://${domain}/api/${SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`;
6012
+ var storefrontFetch = async ({ fetcher, domain, storefrontAccessToken, query, variables }) => {
6013
+ return request({
6014
+ method: "POST",
6015
+ url: storefrontUrl(domain),
6016
+ headers: {
6017
+ "X-Shopify-Storefront-Access-Token": storefrontAccessToken
6018
+ },
6019
+ body: {
6020
+ query,
6021
+ ...variables && { variables }
6022
+ }
6023
+ });
6024
+ };
6025
+ var shopCountryCache = {};
6026
+ var getShopCountry = async ({ domain, fetcher, storefrontAccessToken }) => {
6027
+ var _a, _b;
6028
+ if (shopCountryCache[domain]) return shopCountryCache[domain];
6029
+ const { data: data2 } = await storefrontFetch({
6030
+ fetcher,
6031
+ domain,
6032
+ storefrontAccessToken,
6033
+ query: `{
6034
+ shop {
6035
+ paymentSettings {
6036
+ countryCode
6037
+ }
6038
+ }
6039
+ }`
6040
+ });
6041
+ const countryCode = ((_b = (_a = data2 == null ? void 0 : data2.shop) == null ? void 0 : _a.paymentSettings) == null ? void 0 : _b.countryCode) || null;
6042
+ if (countryCode) shopCountryCache[domain] = countryCode;
6043
+ return countryCode;
6044
+ };
6045
+ var OUT_OF_STOCK_WARNING_CODES = [
6046
+ "MERCHANDISE_OUT_OF_STOCK",
6047
+ "MERCHANDISE_NOT_ENOUGH_STOCK"
6048
+ ];
6049
+ var probeVariantsOutOfStock = async ({ fetcher, domain, storefrontAccessToken, variantIds, countryCode }) => {
6050
+ var _a, _b;
6051
+ if (!(variantIds == null ? void 0 : variantIds.length)) return [];
6052
+ const resolvedCountry = countryCode || await getShopCountry({ domain, fetcher, storefrontAccessToken });
6053
+ const { data: data2, errors } = await storefrontFetch({
6054
+ fetcher,
6055
+ domain,
6056
+ storefrontAccessToken,
6057
+ query: `
6058
+ mutation probe( $input : CartInput! ){
6059
+ cartCreate( input : $input ){
6060
+ cart {
6061
+ lines( first : 250 ){
6062
+ edges {
6063
+ node {
6064
+ id
6065
+ merchandise {
6066
+ ... on ProductVariant {
6067
+ id
6068
+ }
6069
+ }
6070
+ }
6071
+ }
6072
+ }
6073
+ }
6074
+ warnings {
6075
+ code
6076
+ message
6077
+ target
6078
+ }
6079
+ }
6080
+ }
6081
+ `,
6082
+ variables: {
6083
+ input: {
6084
+ lines: variantIds.map((merchandiseId) => ({
6085
+ merchandiseId,
6086
+ quantity: 1
6087
+ })),
6088
+ ...resolvedCountry && {
6089
+ buyerIdentity: {
6090
+ countryCode: resolvedCountry
6091
+ }
6092
+ }
6093
+ }
6094
+ }
6095
+ });
6096
+ if (errors) return [];
6097
+ const payload = data2 == null ? void 0 : data2.cartCreate;
6098
+ const soldOut = new Set(
6099
+ ((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)
6100
+ );
6101
+ 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 }) => {
6102
+ var _a2;
6103
+ return (_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.id;
6104
+ }).filter(Boolean);
6105
+ };
6106
+ var productsQuery = (search, cursor, limit, sort) => {
6107
+ const resolved = resolveProductSort(sort);
6108
+ const sortArgs = resolved ? `, sortKey: ${resolved.sortKey}, reverse: ${resolved.reverse}` : "";
6109
+ return `{
6110
+ products(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}${sortArgs}) {
6111
+ edges {
6112
+ node {
6113
+ id
6114
+ title
6115
+ description
6116
+ handle
6117
+ productType
6118
+ featuredImage {
6119
+ url
6120
+ }
6121
+ variants(first: 10) {
6122
+ edges {
6123
+ node {
6124
+ id
6125
+ title
6126
+ image {
6127
+ url
6128
+ }
6129
+ price {
6130
+ amount
6131
+ currencyCode
6132
+ }
6133
+ compareAtPrice {
6134
+ amount
6135
+ currencyCode
6136
+ }
6137
+ availableForSale
6138
+ requiresShipping
6139
+ }
6140
+ }
6141
+ }
6142
+ }
6143
+ }
6144
+ pageInfo {
6145
+ endCursor
6146
+ hasNextPage
6147
+ hasPreviousPage
6148
+ startCursor
6149
+ }
6150
+ }
6151
+ }`;
6152
+ };
6153
+ var productQuery = (productId) => `{
6154
+ product(id: "${productId}") {
6155
+ id
6156
+ title
6157
+ description
6158
+ handle
6159
+ productType
6160
+ featuredImage {
6161
+ url
6162
+ }
6163
+ variants(first: 10) {
6164
+ edges {
6165
+ node {
6166
+ id
6167
+ title
6168
+ image {
6169
+ url
6170
+ }
6171
+ price {
6172
+ amount
6173
+ currencyCode
6174
+ }
6175
+ compareAtPrice {
6176
+ amount
6177
+ currencyCode
6178
+ }
6179
+ availableForSale
6180
+ requiresShipping
6181
+ }
6182
+ }
6183
+ }
6184
+ }
6185
+ }`;
6186
+ var variantsQuery = (productId, cursor) => `{
6187
+ product(id: "${productId}") {
6188
+ variants(first: 250${cursor ? `, after: "${cursor}"` : ""}) {
6189
+ edges {
6190
+ node {
6191
+ id
6192
+ title
6193
+ image {
6194
+ url
6195
+ }
6196
+ price {
6197
+ amount
6198
+ currencyCode
6199
+ }
6200
+ compareAtPrice {
6201
+ amount
6202
+ currencyCode
6203
+ }
6204
+ availableForSale
6205
+ requiresShipping
6206
+ }
6207
+ }
6208
+ pageInfo {
6209
+ hasNextPage
6210
+ endCursor
6211
+ }
6212
+ }
6213
+ }
6214
+ }`;
6215
+ var getProducts = async ({ fetcher, domain, storefrontAccessToken, search, cursor, limit, sort }) => {
6216
+ const { data: data2, errors } = await storefrontFetch({
6217
+ fetcher,
6218
+ domain,
6219
+ storefrontAccessToken,
6220
+ query: productsQuery(search, cursor, limit, sort)
6221
+ });
6222
+ if (errors) {
6223
+ throw new Error("Failed to fetch Shopify products");
6224
+ }
6225
+ ;
6226
+ return data2 == null ? void 0 : data2.products;
6227
+ };
6228
+ var getProduct = async ({ fetcher, domain, storefrontAccessToken, productId }) => {
6229
+ var _a;
6230
+ const { data: data2, errors } = await storefrontFetch({
6231
+ fetcher,
6232
+ domain,
6233
+ storefrontAccessToken,
6234
+ query: productQuery(productId)
6235
+ });
6236
+ if (errors) {
6237
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6238
+ }
6239
+ ;
6240
+ if (!(data2 == null ? void 0 : data2.product)) {
6241
+ throw new Error("Shopify product not found");
6242
+ }
6243
+ ;
6244
+ return data2.product;
6245
+ };
6246
+ var getProductVariantsPage = async ({ fetcher, domain, storefrontAccessToken, productId, cursor }) => {
6247
+ var _a;
6248
+ const { data: data2, errors } = await storefrontFetch({
6249
+ fetcher,
6250
+ domain,
6251
+ storefrontAccessToken,
6252
+ query: variantsQuery(productId, cursor)
6253
+ });
6254
+ if (errors) {
6255
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6256
+ }
6257
+ ;
6258
+ if (!(data2 == null ? void 0 : data2.product)) {
6259
+ throw new Error("Shopify product not found");
6260
+ }
6261
+ ;
6262
+ return data2.product.variants;
6263
+ };
6264
+ var CART_FIELDS = `
6265
+ id
6266
+ checkoutUrl
6267
+ lines(first: 100) {
6268
+ edges {
6269
+ node {
6270
+ id
6271
+ quantity
6272
+ merchandise {
6273
+ ... on ProductVariant {
6274
+ id
6275
+ title
6276
+ availableForSale
6277
+ image {
6278
+ url
6279
+ }
6280
+ price {
6281
+ amount
6282
+ currencyCode
6283
+ }
6284
+ product {
6285
+ title
6286
+ }
6287
+ }
6288
+ }
6289
+ }
6290
+ }
6291
+ }
6292
+ `;
6293
+ var mapCart = (cart) => {
6294
+ var _a;
6295
+ if (!cart) return null;
6296
+ return {
6297
+ checkoutUrl: cart.checkoutUrl,
6298
+ id: cart.id,
6299
+ lines: (((_a = cart.lines) == null ? void 0 : _a.edges) || []).map(({ node }) => {
6300
+ var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
6301
+ return {
6302
+ availableForSale: ((_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.availableForSale) ?? true,
6303
+ currency: ((_c = (_b = node == null ? void 0 : node.merchandise) == null ? void 0 : _b.price) == null ? void 0 : _c.currencyCode) || null,
6304
+ id: node.id,
6305
+ image: ((_e = (_d = node == null ? void 0 : node.merchandise) == null ? void 0 : _d.image) == null ? void 0 : _e.url) || null,
6306
+ price: ((_g = (_f = node == null ? void 0 : node.merchandise) == null ? void 0 : _f.price) == null ? void 0 : _g.amount) || null,
6307
+ productTitle: ((_i = (_h = node == null ? void 0 : node.merchandise) == null ? void 0 : _h.product) == null ? void 0 : _i.title) || null,
6308
+ quantity: node.quantity,
6309
+ variantId: (_j = node == null ? void 0 : node.merchandise) == null ? void 0 : _j.id,
6310
+ variantTitle: ((_k = node == null ? void 0 : node.merchandise) == null ? void 0 : _k.title) || null
6311
+ };
6312
+ })
6313
+ };
6314
+ };
6315
+ var firstUserError = (userErrors) => {
6316
+ const error = userErrors == null ? void 0 : userErrors[0];
6317
+ if (!error) return null;
6318
+ const message = new Error(error.message || "Shopify cart error");
6319
+ message.userError = true;
6320
+ return message;
6321
+ };
6322
+ var createCheckoutToken = async ({ fetcher, clientId, clientSecret } = {}) => {
6323
+ if (!clientId || !clientSecret) {
6324
+ throw new Error("createCheckoutToken requires clientId and clientSecret");
6325
+ }
6326
+ ;
6327
+ const response = await request({
6328
+ fetcher,
6329
+ method: "POST",
6330
+ url: "https://api.shopify.com/auth/access_token",
6331
+ body: {
6332
+ client_id: clientId,
6333
+ client_secret: clientSecret,
6334
+ grant_type: "client_credentials"
6335
+ }
6336
+ });
6337
+ const token = response == null ? void 0 : response.access_token;
6338
+ if (!token) {
6339
+ throw new Error("Failed to create Shopify checkout token");
6340
+ }
6341
+ ;
6342
+ return token;
6343
+ };
6344
+ var cartCreate = async ({ fetcher, domain, storefrontAccessToken, lines }) => {
6345
+ var _a, _b;
6346
+ const { data: data2, errors } = await storefrontFetch({
6347
+ fetcher,
6348
+ domain,
6349
+ storefrontAccessToken,
6350
+ query: `
6351
+ mutation cartCreate($input: CartInput!) {
6352
+ cartCreate(input: $input) {
6353
+ cart {
6354
+ ${CART_FIELDS}
6355
+ }
6356
+ userErrors {
6357
+ field
6358
+ message
6359
+ }
6360
+ }
6361
+ }
6362
+ `,
6363
+ variables: {
6364
+ input: {
6365
+ lines
6366
+ }
6367
+ }
6368
+ });
6369
+ if (errors) {
6370
+ throw new Error("Failed to create Shopify cart");
6371
+ }
6372
+ ;
6373
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartCreate) == null ? void 0 : _a.userErrors);
6374
+ if (userError) throw userError;
6375
+ return mapCart((_b = data2 == null ? void 0 : data2.cartCreate) == null ? void 0 : _b.cart);
6376
+ };
6377
+ var cartGet = async ({ fetcher, domain, storefrontAccessToken, cartId }) => {
6378
+ const { data: data2, errors } = await storefrontFetch({
6379
+ fetcher,
6380
+ domain,
6381
+ storefrontAccessToken,
6382
+ query: `
6383
+ query cart($id: ID!) {
6384
+ cart(id: $id) {
6385
+ ${CART_FIELDS}
6386
+ }
6387
+ }
6388
+ `,
6389
+ variables: {
6390
+ id: cartId
6391
+ }
6392
+ });
6393
+ if (errors) {
6394
+ throw new Error("Failed to fetch Shopify cart");
6395
+ }
6396
+ ;
6397
+ return mapCart(data2 == null ? void 0 : data2.cart);
6398
+ };
6399
+ var cartLinesAdd = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6400
+ var _a, _b;
6401
+ const { data: data2, errors } = await storefrontFetch({
6402
+ fetcher,
6403
+ domain,
6404
+ storefrontAccessToken,
6405
+ query: `
6406
+ mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
6407
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
6408
+ cart {
6409
+ ${CART_FIELDS}
6410
+ }
6411
+ userErrors {
6412
+ field
6413
+ message
6414
+ }
6415
+ }
6416
+ }
6417
+ `,
6418
+ variables: {
6419
+ cartId,
6420
+ lines
6421
+ }
6422
+ });
6423
+ if (errors) {
6424
+ throw new Error("Failed to add Shopify cart lines");
6425
+ }
6426
+ ;
6427
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesAdd) == null ? void 0 : _a.userErrors);
6428
+ if (userError) throw userError;
6429
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesAdd) == null ? void 0 : _b.cart);
6430
+ };
6431
+ var cartLinesRemove = async ({ fetcher, domain, storefrontAccessToken, cartId, lineIds }) => {
6432
+ var _a, _b;
6433
+ const { data: data2, errors } = await storefrontFetch({
6434
+ fetcher,
6435
+ domain,
6436
+ storefrontAccessToken,
6437
+ query: `
6438
+ mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
6439
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
6440
+ cart {
6441
+ ${CART_FIELDS}
6442
+ }
6443
+ userErrors {
6444
+ field
6445
+ message
6446
+ }
6447
+ }
6448
+ }
6449
+ `,
6450
+ variables: {
6451
+ cartId,
6452
+ lineIds
6453
+ }
6454
+ });
6455
+ if (errors) {
6456
+ throw new Error("Failed to remove Shopify cart lines");
6457
+ }
6458
+ ;
6459
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesRemove) == null ? void 0 : _a.userErrors);
6460
+ if (userError) throw userError;
6461
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesRemove) == null ? void 0 : _b.cart);
6462
+ };
6463
+ var cartLinesUpdate = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6464
+ var _a, _b;
6465
+ const { data: data2, errors } = await storefrontFetch({
6466
+ fetcher,
6467
+ domain,
6468
+ storefrontAccessToken,
6469
+ query: `
6470
+ mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
6471
+ cartLinesUpdate(cartId: $cartId, lines: $lines) {
6472
+ cart {
6473
+ ${CART_FIELDS}
6474
+ }
6475
+ userErrors {
6476
+ field
6477
+ message
6478
+ }
6479
+ }
6480
+ }
6481
+ `,
6482
+ variables: {
6483
+ cartId,
6484
+ lines
6485
+ }
6486
+ });
6487
+ if (errors) {
6488
+ throw new Error("Failed to update Shopify cart lines");
6489
+ }
6490
+ ;
6491
+ const userError = firstUserError((_a = data2 == null ? void 0 : data2.cartLinesUpdate) == null ? void 0 : _a.userErrors);
6492
+ if (userError) throw userError;
6493
+ return mapCart((_b = data2 == null ? void 0 : data2.cartLinesUpdate) == null ? void 0 : _b.cart);
6494
+ };
6495
+
6496
+ // lib/email.js
6497
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
6498
+ var toCanonicalEmail = (value) => {
6499
+ if (!value || typeof value !== "string") return null;
6500
+ const email = value.trim().toLowerCase();
6501
+ const at = email.lastIndexOf("@");
6502
+ if (at < 1 || at === email.length - 1) return null;
6503
+ let local = email.slice(0, at);
6504
+ const domain = email.slice(at + 1);
6505
+ const plus = local.indexOf("+");
6506
+ if (plus > 0) local = local.slice(0, plus);
6507
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
6508
+ if (!local) return null;
6509
+ return local + "@" + domain;
6510
+ };
6511
+
6512
+ // lib/oauth/index.js
6513
+ var lifetimes = {
6514
+ access: 60 * 60 * 8,
6515
+ // 8 hours
6516
+ authorizationCode: 10 * 60,
6517
+ // 10 minutes
6518
+ code: 15 * 60,
6519
+ // 15 minutes (email OTC)
6520
+ refresh: 30 * 24 * 60 * 60
6521
+ // 30 days
6522
+ };
6523
+ var tokenTypes = {
6524
+ access: "access",
6525
+ oauthState: "oauth.state",
6526
+ pat: "pat",
6527
+ refresh: "refresh"
6528
+ };
6529
+ var scopes = [
6530
+ "profile:read",
6531
+ "profile:write",
6532
+ "organization:read",
6533
+ "organization:write",
6534
+ "campaigns:read",
6535
+ "campaigns:write",
6536
+ "contacts:read",
6537
+ "contacts:write",
6538
+ "workflows:read",
6539
+ "workflows:write",
6540
+ "connections:read",
6541
+ "connections:write",
6542
+ "billing:read",
6543
+ "billing:write",
6544
+ "admin"
6545
+ ];
6546
+ var developer = [
6547
+ "profile:read",
6548
+ "organization:read",
6549
+ "campaigns:read",
6550
+ "campaigns:write",
6551
+ "contacts:read",
6552
+ "contacts:write",
6553
+ "workflows:read",
6554
+ "workflows:write",
6555
+ "connections:read",
6556
+ "connections:write"
6557
+ ];
6558
+ var scopeLabels = {
6559
+ "profile:read": "Read own profile",
6560
+ "profile:write": "Update own profile",
6561
+ "organization:read": "Read organization info",
6562
+ "organization:write": "Manage organization",
6563
+ "campaigns:read": "Read campaigns",
6564
+ "campaigns:write": "Manage campaigns",
6565
+ "contacts:read": "Read contacts",
6566
+ "contacts:write": "Manage contacts",
6567
+ "workflows:read": "Read workflows",
6568
+ "workflows:write": "Manage workflows",
6569
+ "connections:read": "Read integrations",
6570
+ "connections:write": "Manage integrations",
6571
+ "billing:read": "Read billing",
6572
+ "billing:write": "Manage billing",
6573
+ admin: "Admin access"
6574
+ };
6575
+ var toOption = (scope) => ({
6576
+ key: scopeLabels[scope] || scope,
6577
+ value: scope
6578
+ });
6579
+ var developerOptions = developer.map(toOption);
6580
+ var scopeOptions = scopes.map(toOption);
6581
+ var hashToken = (raw) => hash(raw, process.env.HMAC_OAUTH_TOKEN_KEY);
6582
+
6583
+ // lib/sanitize.js
6584
+ var import_disposable_email_domains = __toESM(require("disposable-email-domains/index.js"), 1);
6585
+ var disposableBlacklist = new Set(import_disposable_email_domains.default.map((d) => d.toLowerCase()));
6586
+ var sanitizeDomain = (value, pattern) => {
6587
+ if (!value) return value;
6588
+ const cleaned = value.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
6589
+ if (pattern) {
6590
+ const match = cleaned.match(pattern);
6591
+ return match ? match[0] : cleaned;
6592
+ }
6593
+ return cleaned;
6594
+ };
6595
+
6596
+ // lib/slugify.js
6597
+ var import_slugify = __toESM(require("slugify"), 1);
6598
+ var slugify = (value, nochars = false) => {
6599
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
6600
+ const replacement = nochars ? "" : "-";
6601
+ return (0, import_slugify.default)(
6602
+ value,
6603
+ {
6604
+ remove,
6605
+ replacement,
6606
+ lower: true,
6607
+ trim: true
6608
+ }
6609
+ );
6610
+ };
6611
+
6612
+ // lib/connections/manifests/shopify.js
6613
+ var client = Object.freeze({
6614
+ admin: admin_exports,
6615
+ billing: billing_exports,
6616
+ oauth: oauth_exports,
6617
+ partner: partner_exports,
6618
+ storefront: storefront_exports
6619
+ });
6620
+ var toLine = ({
6621
+ price,
6622
+ product_id: productId,
6623
+ quantity,
6624
+ title,
6625
+ variant_id: variantId,
6626
+ variant_title: variantTitle
6627
+ }) => ({
6628
+ price: parseFloat(price) || 0,
6629
+ productId: productId ? "gid://shopify/Product/" + productId : null,
6630
+ quantity: quantity || 1,
6631
+ title: title || null,
6632
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
6633
+ variantTitle: variantTitle || null
6634
+ });
6635
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
6636
+ (acc, item) => {
6637
+ const attrs = (item.properties || []).reduce(
6638
+ (map, { name, value }) => {
6639
+ map[name] = value;
6640
+ return map;
6641
+ },
6642
+ {}
6643
+ );
6644
+ if (!attrs["_drwbrdg_ca"]) return acc;
6645
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
6646
+ const line = toLine(item);
6647
+ acc.attributedGross += line.price * line.quantity;
6648
+ acc.attributedLines.push(line);
6649
+ return acc;
6650
+ },
6651
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
6652
+ );
6653
+ var generateDiscountCode = (0, import_nanoid.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
6654
+ var blockedReason = (discount) => {
6655
+ var _a;
6656
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
6657
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
6658
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
6659
+ 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.";
6660
+ }
6661
+ ;
6662
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
6663
+ return "This discount has reached its total usage limit.";
6664
+ }
6665
+ ;
6666
+ return null;
6667
+ };
6668
+ var discountWarning = (discount) => {
6669
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
6670
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
6671
+ }
6672
+ ;
6673
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
6674
+ return null;
6675
+ };
6676
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
6677
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
6678
+ var OAUTH_ERROR_SOURCE = "oauth";
6679
+ var BILLING_ERROR_SOURCE = "billing";
6680
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
6681
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
6682
+ var inbound = {
6683
+ headers: {
6684
+ event: "x-shopify-topic",
6685
+ id: "x-shopify-webhook-id",
6686
+ shop: "x-shopify-shop-domain",
6687
+ signature: "x-shopify-hmac-sha256"
6688
+ },
6689
+ signature: {
6690
+ algorithm: "sha256",
6691
+ encoding: "base64",
6692
+ secret: "SHOPIFY_API_SECRET"
6693
+ }
6694
+ };
6695
+ var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
4968
6696
  "customers/data_request",
4969
6697
  "customers/redact",
4970
6698
  "shop/redact"
4971
6699
  ]);
6700
+ var refusal = (message, status) => Object.assign(new Error(message), { status });
6701
+ var LINK_TOKEN_TTL_MS = 30 * 60 * 1e3;
6702
+ var CONNECTABLE_STATUSES = ["active", "unsubscribed"];
6703
+ var merchantOrganizations = ({ read, user }) => read.aggregate({
6704
+ collection: "organization",
6705
+ pipeline: [
6706
+ // A member qualifies only at `manage`; an owner always qualifies.
6707
+ {
6708
+ $lookup: {
6709
+ as: "membership",
6710
+ from: "member",
6711
+ let: { organization: "$id" },
6712
+ pipeline: [
6713
+ {
6714
+ $match: {
6715
+ $expr: { $eq: ["$organization", "$$organization"] },
6716
+ "capabilities.connections": "manage",
6717
+ status: "accepted",
6718
+ user
6719
+ }
6720
+ },
6721
+ { $limit: 1 },
6722
+ { $project: { _id: 0, organization: 1 } }
6723
+ ]
6724
+ }
6725
+ },
6726
+ {
6727
+ $match: {
6728
+ status: { $ne: "canceled" },
6729
+ $or: [{ owner: user }, { "membership.0": { $exists: true } }]
6730
+ }
6731
+ },
6732
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
6733
+ // the list can read the real plan and conversion rate. Without it every org
6734
+ // rendered as free tier.
6735
+ { $lookup: { as: "subscription", foreignField: "id", from: "subscription", localField: "subscription" } },
6736
+ { $unwind: { path: "$subscription", preserveNullAndEmptyArrays: true } },
6737
+ {
6738
+ $lookup: {
6739
+ as: "connection",
6740
+ foreignField: "organization",
6741
+ from: "connection",
6742
+ localField: "id",
6743
+ pipeline: [
6744
+ { $match: { slug: "shopify" } },
6745
+ { $limit: 1 },
6746
+ { $project: { _id: 0, id: 1, shop: 1, status: 1 } }
6747
+ ]
6748
+ }
6749
+ },
6750
+ { $unwind: { path: "$connection", preserveNullAndEmptyArrays: true } },
6751
+ { $project: { _id: 0, billingStatus: "$status", connection: 1, id: 1, name: "$title", subscription: 1 } },
6752
+ { $sort: { name: 1 } }
6753
+ ]
6754
+ });
6755
+ var storePlan = async ({ record, shopify }) => {
6756
+ var _a;
6757
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
6758
+ if (!(settings == null ? void 0 : settings.accessToken)) return { active: false, name: null };
6759
+ try {
6760
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6761
+ adminAccessToken: settings.accessToken,
6762
+ domain: record.shop
6763
+ });
6764
+ const active = (subscriptions || []).length > 0;
6765
+ const metered = active && subscriptions.some((subscription) => subscription.metered === true) ? true : null;
6766
+ return { active, metered, name: active ? ((_a = subscriptions[0]) == null ? void 0 : _a.name) || null : null };
6767
+ } catch (_) {
6768
+ return { active: false, name: null, unavailable: true };
6769
+ }
6770
+ };
6771
+ var releaseApprovals = async ({ context, read, shopify }) => {
6772
+ var _a;
6773
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
6774
+ if (!shop) throw refusal("shop is required", 400);
6775
+ const record = await read.get({ collection: "shop", query: { shop } });
6776
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
6777
+ if (!(settings == null ? void 0 : settings.accessToken)) throw refusal("Store is not installed", 409);
6778
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6779
+ adminAccessToken: settings.accessToken,
6780
+ domain: shop
6781
+ });
6782
+ for (const subscription of subscriptions || []) {
6783
+ const data2 = await shopify.admin.adminFetch({
6784
+ adminAccessToken: settings.accessToken,
6785
+ domain: shop,
6786
+ query: "mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }",
6787
+ variables: { id: subscription.id }
6788
+ });
6789
+ const [userError] = ((_a = data2 == null ? void 0 : data2.appSubscriptionCancel) == null ? void 0 : _a.userErrors) || [];
6790
+ if (userError) throw refusal(userError.message, 422);
6791
+ }
6792
+ return {
6793
+ message: "Released " + (subscriptions || []).length + " approval(s).",
6794
+ request: { shop },
6795
+ result: { ok: true }
6796
+ };
6797
+ };
6798
+ var LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
6799
+ var meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
6800
+ var _a;
6801
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain: shop });
6802
+ let metered = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
6803
+ if (!((partner == null ? void 0 : partner.partnerToken) && (partner == null ? void 0 : partner.partnerOrgId) && (partner == null ? void 0 : partner.partnerAppId) && shopId)) {
6804
+ return { boundaries: [], ledger: false, metered };
6805
+ }
6806
+ const accruals = await shopify.partner.getUsageChargeEvents({
6807
+ appId: "gid://shopify/App/" + partner.partnerAppId,
6808
+ first: 10,
6809
+ occurredAtMin: ledgerFloor.toISOString(),
6810
+ organizationId: partner.partnerOrgId,
6811
+ partnerToken: partner.partnerToken,
6812
+ shopId: "gid://shopify/Shop/" + shopId
6813
+ });
6814
+ const boundaries = (accruals || []).filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
6815
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
6816
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
6817
+ return { boundaries, ledger: true, metered };
6818
+ };
6819
+ var toVariant = (edge) => {
6820
+ var _a, _b, _c, _d;
6821
+ return {
6822
+ availableForSale: edge.node.availableForSale || false,
6823
+ currency: ((_a = edge.node.price) == null ? void 0 : _a.currencyCode) || null,
6824
+ id: edge.node.id,
6825
+ image: ((_b = edge.node.image) == null ? void 0 : _b.url) || null,
6826
+ price: parseFloat((_c = edge.node.price) == null ? void 0 : _c.amount) || null,
6827
+ requiresShipping: edge.node.requiresShipping || false,
6828
+ sale: parseFloat((_d = edge.node.compareAtPrice) == null ? void 0 : _d.amount) || null,
6829
+ title: edge.node.title !== "Default Title" ? edge.node.title : null
6830
+ };
6831
+ };
6832
+ var applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
6833
+ try {
6834
+ const outOfStock = new Set(await shopify.storefront.probeVariantsOutOfStock({
6835
+ domain,
6836
+ storefrontAccessToken,
6837
+ variantIds: variants.map((variant) => variant.id)
6838
+ }));
6839
+ return variants.map((variant) => ({
6840
+ ...variant,
6841
+ availableForSale: variant.availableForSale && !outOfStock.has(variant.id)
6842
+ }));
6843
+ } catch (_) {
6844
+ return variants;
6845
+ }
6846
+ };
6847
+ var sendFeedback = async ({ adminToken, connection: connection2, messages, productId, shopify, state }) => {
6848
+ try {
6849
+ await shopify.admin.sendProductResourceFeedback({
6850
+ adminAccessToken: await adminToken(),
6851
+ domain: connection2.shop,
6852
+ messages,
6853
+ productId,
6854
+ state
6855
+ });
6856
+ return null;
6857
+ } catch (error) {
6858
+ return error;
6859
+ }
6860
+ };
6861
+ var resolveProduct = async ({ connection: connection2, context, read }) => {
6862
+ const byId = (context == null ? void 0 : context.product) ? await read.get({ collection: "product", query: { id: context.product } }) : null;
6863
+ const row2 = byId || ((context == null ? void 0 : context.providerId) && ((context == null ? void 0 : context.shop) || (connection2 == null ? void 0 : connection2.shop)) ? await read.get({
6864
+ collection: "product",
6865
+ query: {
6866
+ "provider.id": context.providerId,
6867
+ "provider.slug": "shopify",
6868
+ "source.domain": context.shop || connection2.shop
6869
+ }
6870
+ }) : null);
6871
+ return row2;
6872
+ };
6873
+ var UNAVAILABLE_FEEDBACK = "Drawbridge can't display this product. Check it is active and available in the United States.";
6874
+ var NOT_FOUND = "Shopify product not found";
6875
+ var syncProduct = async ({ adminToken, connection: connection2, context, read, settings, shopify }) => {
6876
+ var _a, _b, _c;
6877
+ const row2 = await resolveProduct({ connection: connection2, context, read });
6878
+ if (!row2) return { message: "No product row to sync.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
6879
+ const domain = connection2.shop;
6880
+ const providerId = (_a = row2.provider) == null ? void 0 : _a.id;
6881
+ const request2 = { product: row2.id, providerId: providerId || null, shop: domain };
6882
+ let product;
6883
+ try {
6884
+ product = await shopify.storefront.getProduct({
6885
+ domain,
6886
+ productId: providerId,
6887
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
6888
+ });
6889
+ } catch (error) {
6890
+ if (error.message !== NOT_FOUND) throw error;
6891
+ const failed2 = await sendFeedback({
6892
+ adminToken,
6893
+ connection: connection2,
6894
+ messages: [UNAVAILABLE_FEEDBACK],
6895
+ productId: providerId,
6896
+ shopify,
6897
+ state: "REQUIRES_ACTION"
6898
+ });
6899
+ return {
6900
+ failed: failed2 ? [failed2.message] : void 0,
6901
+ message: "Storefront cannot see this product \u2014 deactivated.",
6902
+ request: request2,
6903
+ result: { status: "inactive" },
6904
+ writes: [
6905
+ {
6906
+ collection: "product",
6907
+ data: { $set: { status: "inactive" } },
6908
+ operation: "update",
6909
+ query: { id: row2.id }
6910
+ },
6911
+ {
6912
+ collection: "advertisement",
6913
+ data: { $set: { status: "drafted" } },
6914
+ multiple: true,
6915
+ operation: "update",
6916
+ query: { product: row2.id }
6917
+ }
6918
+ ]
6919
+ };
6920
+ }
6921
+ const variants = await applyOutOfStock({
6922
+ domain,
6923
+ shopify,
6924
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
6925
+ variants: (((_b = product == null ? void 0 : product.variants) == null ? void 0 : _b.edges) || []).map(toVariant)
6926
+ });
6927
+ const failed = await sendFeedback({
6928
+ adminToken,
6929
+ connection: connection2,
6930
+ messages: [],
6931
+ productId: providerId,
6932
+ shopify,
6933
+ state: "ACCEPTED"
6934
+ });
6935
+ return {
6936
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
6937
+ // written above; a product with thousands of variants becomes N short
6938
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
6939
+ enqueues: [{
6940
+ data: { operation: "variants", product: row2.id },
6941
+ name: "sync",
6942
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
6943
+ queue: "product.shopify.variants"
6944
+ }],
6945
+ failed: failed ? [failed.message] : void 0,
6946
+ message: "Synced " + (product.title || "product") + ".",
6947
+ request: request2,
6948
+ result: { status: "active", variants: variants.length },
6949
+ writes: [{
6950
+ collection: "product",
6951
+ data: {
6952
+ $set: {
6953
+ description: product.description || null,
6954
+ image: ((_c = product.featuredImage) == null ? void 0 : _c.url) || null,
6955
+ // A successful sync PROVES channel visibility, which reverses the
6956
+ // deactivation above. Ads stay drafted.
6957
+ status: "active",
6958
+ title: product.title || null,
6959
+ type: product.productType || null,
6960
+ url: domain && product.handle ? "https://" + domain + "/products/" + product.handle : null,
6961
+ variants
6962
+ }
6963
+ },
6964
+ operation: "update",
6965
+ query: { id: row2.id }
6966
+ }]
6967
+ };
6968
+ };
6969
+ var syncVariants = async ({ connection: connection2, context, read, settings, shopify }) => {
6970
+ var _a, _b, _c;
6971
+ const row2 = await resolveProduct({ connection: connection2, context, read });
6972
+ if (!row2) return { message: "No product row to paginate.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
6973
+ const cursor = (context == null ? void 0 : context.cursor) || null;
6974
+ const request2 = { cursor, product: row2.id };
6975
+ let page2;
6976
+ try {
6977
+ page2 = await shopify.storefront.getProductVariantsPage({
6978
+ cursor,
6979
+ domain: connection2.shop,
6980
+ productId: (_a = row2.provider) == null ? void 0 : _a.id,
6981
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
6982
+ });
6983
+ } catch (error) {
6984
+ if (error.message !== NOT_FOUND) throw error;
6985
+ return { message: "Storefront lost this product mid-pagination \u2014 stopping.", request: request2, skipped: true };
6986
+ }
6987
+ const variants = await applyOutOfStock({
6988
+ domain: connection2.shop,
6989
+ shopify,
6990
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
6991
+ variants: (page2.edges || []).map(toVariant)
6992
+ });
6993
+ return {
6994
+ ...((_b = page2.pageInfo) == null ? void 0 : _b.hasNextPage) && { enqueues: [{
6995
+ data: { cursor: page2.pageInfo.endCursor, operation: "variants", product: row2.id },
6996
+ name: "page",
6997
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
6998
+ queue: "product.shopify.variants"
6999
+ }] },
7000
+ message: variants.length + " variant(s) on this page.",
7001
+ request: request2,
7002
+ result: { hasNextPage: Boolean((_c = page2.pageInfo) == null ? void 0 : _c.hasNextPage), variants: variants.length },
7003
+ writes: [{
7004
+ collection: "product",
7005
+ data: cursor ? { $push: { variants: { $each: variants } } } : { $set: { variants } },
7006
+ operation: "update",
7007
+ query: { id: row2.id }
7008
+ }]
7009
+ };
7010
+ };
4972
7011
  var shopify_default2 = {
4973
7012
  // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
4974
7013
  // a BUTTON LABEL inside the copy object next to a url — the title and the
@@ -5155,8 +7194,8 @@ var shopify_default2 = {
5155
7194
  // string; this answers what is missing from it.
5156
7195
  //
5157
7196
  // `shopify` is injected for the same reason it is everywhere else — this
5158
- // package cannot import @drawbridge/shopify, which depends on it.
5159
- scopes: ({ scope }, { shopify } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
7197
+ // client is imported here and defaulted, never wired in by a caller.
7198
+ scopes: ({ scope }, { shopify = client } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
5160
7199
  // Shopify's install grant is exchanged inside its own app flow, not
5161
7200
  // through the shared OAuth runner.
5162
7201
  token: false
@@ -5170,17 +7209,53 @@ var shopify_default2 = {
5170
7209
  // can reference another), `dispatch` (the caller's own coordinator table,
5171
7210
  // for the hooks that are dispatches).
5172
7211
  commerce: {
5173
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
5174
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
5175
- // exactly why it never became a hook: a hook is reached to have its effects
5176
- // performed, and this one has none to perform. It moves here when the vendor
5177
- // client moves into the manifest and `subdomain.js` stops importing the
5178
- // package directly.
5179
- cart: false,
7212
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
7213
+ // the storefront client from api/route/subdomain.js.
7214
+ //
7215
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
7216
+ // become a hook a hook is usually reached to have its effects performed
7217
+ // and this one has none. That reasoning was wrong: a hook is also the one
7218
+ // place a vendor's client is allowed to be called from, and leaving the
7219
+ // cart out meant a public route importing a vendor SDK directly.
7220
+ //
7221
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
7222
+ // uses for its topic table. Six slots would be six names in the closed
7223
+ // vocabulary for one question — what does this shopper's cart look like
7224
+ // now — asked six ways.
7225
+ //
7226
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
7227
+ // mutations answer "that variant is sold out" as a userError rather than a
7228
+ // transport failure, and the route it replaced already told the two apart.
7229
+ // runHook carries `status` off a thrown error, so it survives to the
7230
+ // caller; the rest of a thrown error's shape does not.
7231
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {}) => {
7232
+ const { cartId, lineIds, lines, operation } = context || {};
7233
+ const { domain, storefrontAccessToken } = settings || {};
7234
+ if (operation === "checkout") {
7235
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
7236
+ return { message: "Checkout token minted.", result: { token } };
7237
+ }
7238
+ const storefront = { domain, fetcher, storefrontAccessToken };
7239
+ try {
7240
+ const cart = await ({
7241
+ create: () => shopify.storefront.cartCreate({ ...storefront, lines }),
7242
+ get: () => shopify.storefront.cartGet({ ...storefront, cartId }),
7243
+ linesAdd: () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
7244
+ linesRemove: () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
7245
+ linesUpdate: () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
7246
+ }[operation] || (() => {
7247
+ throw Object.assign(new Error("Unknown cart operation: " + operation), { status: 400 });
7248
+ }))();
7249
+ return { message: "Cart " + operation + ".", request: { cartId: cartId || null, operation }, result: cart };
7250
+ } catch (error) {
7251
+ if (error == null ? void 0 : error.userError) throw Object.assign(error, { status: 400 });
7252
+ throw error;
7253
+ }
7254
+ },
5180
7255
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
5181
7256
  // one lead — which is what lets an order that redeems it be attributed
5182
7257
  // back.
5183
- code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
7258
+ code: async ({ connection: connection2, context, step }, { adminToken, shopify = client } = {}) => {
5184
7259
  var _a;
5185
7260
  const discount = (_a = step.settings) == null ? void 0 : _a.discount;
5186
7261
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
@@ -5226,7 +7301,7 @@ var shopify_default2 = {
5226
7301
  // customer at the store is a support ticket: the context may already
5227
7302
  // carry the id from an earlier step, the lead may already be linked from
5228
7303
  // an earlier run, and Shopify's own get-or-create settles the rest.
5229
- customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
7304
+ customer: async ({ connection: connection2, context }, { adminToken, read, shopify = client } = {}) => {
5230
7305
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
5231
7306
  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 };
5232
7307
  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 };
@@ -5276,6 +7351,30 @@ var shopify_default2 = {
5276
7351
  }]
5277
7352
  };
5278
7353
  },
7354
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
7355
+ // rendered rather than stored on the product, because stock is the one
7356
+ // product fact that is stale the instant it is written down.
7357
+ //
7358
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
7359
+ // caller wanted the total; the one that existed summed it inline, and
7360
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
7361
+ // bookkeeping key got counted as a quantity.
7362
+ inventory: async ({ connection: connection2, context }, { adminToken, shopify = client } = {}) => {
7363
+ const productId = context == null ? void 0 : context.product;
7364
+ const request2 = { product: productId || null, shop: connection2.shop };
7365
+ if (!productId) return { message: "No product id \u2014 nothing to count.", request: request2, result: { total: null }, skipped: true };
7366
+ const adminAccessToken = await adminToken();
7367
+ const byVariant = await shopify.admin.getProductInventory({
7368
+ adminAccessToken,
7369
+ domain: connection2.shop,
7370
+ productId
7371
+ });
7372
+ return {
7373
+ message: "Inventory read.",
7374
+ request: request2,
7375
+ result: { total: Object.values(byVariant || {}).reduce((sum, quantity) => sum + quantity, 0) }
7376
+ };
7377
+ },
5279
7378
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
5280
7379
  // attribution genuinely is: an order can reach Drawbridge two ways and
5281
7380
  // they bill differently.
@@ -5635,10 +7734,28 @@ var shopify_default2 = {
5635
7734
  //
5636
7735
  // The shell has already refused a missing or inactive Shopify connection,
5637
7736
  // so what is left is the two things only this hook can know are wrong.
5638
- product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
7737
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
7738
+ // slots in the closed vocabulary, the same shape inbound.process uses
7739
+ // for its topic table — they are one subject, and a vendor that syncs
7740
+ // products has to answer all three or none.
7741
+ //
7742
+ // record a webhook said a product changed: upsert the row and
7743
+ // queue the pull
7744
+ // sync pull the product from the storefront and write what it
7745
+ // says, including what it says by NOT answering
7746
+ // variants one page of variants, self-chaining
7747
+ //
7748
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
7749
+ // queue/product.js — a shadow implementation of the step this manifest
7750
+ // already declared, importing three of the vendor's client modules from
7751
+ // a worker file.
7752
+ product: async ({ connection: connection2, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {}) => {
7753
+ const operation = (context == null ? void 0 : context.operation) || "record";
7754
+ if (operation === "sync") return syncProduct({ adminToken, connection: connection2, context, read, settings, shopify });
7755
+ if (operation === "variants") return syncVariants({ connection: connection2, context, read, settings, shopify });
5639
7756
  const request2 = {
5640
7757
  numericId: (context == null ? void 0 : context.id) || null,
5641
- organizationId: workflow.organization,
7758
+ organizationId: (workflow == null ? void 0 : workflow.organization) || null,
5642
7759
  title: (context == null ? void 0 : context.title) || null
5643
7760
  };
5644
7761
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
@@ -5688,9 +7805,612 @@ var shopify_default2 = {
5688
7805
  }
5689
7806
  },
5690
7807
  contacts: { remove: false, sync: false },
5691
- // The embedded-app surface. Only a vendor whose merchants arrive from its
5692
- // own app store has one.
5693
- install: false,
7808
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
7809
+ // own app store has one, and these thirteen slots are the thirteen routes
7810
+ // that used to live in api/route/shopify-embedded.js.
7811
+ //
7812
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
7813
+ // holds a controller — `read` is the controller's read methods and nothing
7814
+ // else. Where one REFUSES it throws with a status, and the route answers
7815
+ // what it said. The helpers they share are above the manifest.
7816
+ install: {
7817
+ account: {
7818
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
7819
+ // authenticates the merchant once, top-level, and stashes their user
7820
+ // token in `install.token.save`; App Bridge has already proven shop
7821
+ // control by the time this runs.
7822
+ //
7823
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
7824
+ // token: the presented one must BE this shop's one-shot handoff token,
7825
+ // unexpired, and an access or pat record — a refresh or oauth-state
7826
+ // token that happens to resolve to a user must not qualify, which is
7827
+ // the rule a session is held to in drawbridge-api's middleware.
7828
+ link: async ({ context }, { read } = {}) => {
7829
+ var _a;
7830
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7831
+ const token = context == null ? void 0 : context.token;
7832
+ if (!shop || !token) throw refusal("shop and token are required", 400);
7833
+ const now = /* @__PURE__ */ new Date();
7834
+ const record = await read.get({ collection: "shop", query: { shop } });
7835
+ const stashed = (record == null ? void 0 : record.linkToken) ? (_a = decrypt(record.linkToken)) == null ? void 0 : _a.token : null;
7836
+ const expired = !(record == null ? void 0 : record.linkTokenExpiresAt) || new Date(record.linkTokenExpiresAt) <= now;
7837
+ if (!stashed || expired || stashed !== token) throw refusal("Invalid or expired link token", 401);
7838
+ const granted = await read.get({
7839
+ collection: "token",
7840
+ query: {
7841
+ revoked: false,
7842
+ tokenHash: hashToken(token),
7843
+ type: { $in: [tokenTypes.access, tokenTypes.pat] }
7844
+ }
7845
+ });
7846
+ if (!(granted == null ? void 0 : granted.user) || granted.expiresAt && new Date(granted.expiresAt) <= now) {
7847
+ throw refusal("Invalid or expired link token", 401);
7848
+ }
7849
+ const switching = Boolean(record == null ? void 0 : record.user) && record.user !== granted.user;
7850
+ const connections2 = switching ? await read.aggregate({
7851
+ collection: "connection",
7852
+ pipeline: [
7853
+ { $match: { shop, slug: "shopify" } },
7854
+ { $project: { _id: 0, id: 1 } }
7855
+ ]
7856
+ }) : [];
7857
+ return {
7858
+ message: switching ? "Linked to a different Drawbridge account." : "Linked.",
7859
+ request: { shop },
7860
+ result: { connected: true, switched: switching },
7861
+ // ALL OR NONE. A half-done switch is a store linked to the new
7862
+ // account while the old account's connections still serve from it.
7863
+ transaction: true,
7864
+ writes: [
7865
+ ...(connections2 || []).map((connection2) => ({
7866
+ collection: "connection",
7867
+ operation: "delete",
7868
+ query: { id: connection2.id }
7869
+ })),
7870
+ {
7871
+ collection: "shop",
7872
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: granted.user } },
7873
+ operation: "update",
7874
+ query: { shop }
7875
+ }
7876
+ ]
7877
+ };
7878
+ },
7879
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
7880
+ // the durable link clears; the offline token and the shop record stay,
7881
+ // because the app is still installed and the merchant may link a
7882
+ // different account next.
7883
+ unlink: async ({ context }, { read } = {}) => {
7884
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7885
+ if (!shop) throw refusal("shop is required", 400);
7886
+ const connections2 = await read.aggregate({
7887
+ collection: "connection",
7888
+ pipeline: [
7889
+ { $match: { shop, slug: "shopify" } },
7890
+ { $project: { _id: 0, id: 1 } }
7891
+ ]
7892
+ });
7893
+ return {
7894
+ message: "Unlinked " + (connections2 || []).length + " connection(s).",
7895
+ request: { shop },
7896
+ result: { ok: true },
7897
+ transaction: true,
7898
+ writes: [
7899
+ ...(connections2 || []).map((connection2) => ({
7900
+ collection: "connection",
7901
+ operation: "delete",
7902
+ query: { id: connection2.id }
7903
+ })),
7904
+ {
7905
+ collection: "shop",
7906
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: null } },
7907
+ operation: "update",
7908
+ query: { shop }
7909
+ }
7910
+ ]
7911
+ };
7912
+ }
7913
+ },
7914
+ organizations: {
7915
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
7916
+ // gate in it came from a line in the route it replaces.
7917
+ add: async ({ context }, { currencies: currencies2, mintId, read, shopify = client } = {}) => {
7918
+ var _a, _b;
7919
+ const organization2 = context == null ? void 0 : context.organization;
7920
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7921
+ if (!shop) throw refusal("shop is required", 400);
7922
+ if (!organization2) throw refusal("organization is required", 400);
7923
+ const record = await read.get({ collection: "shop", query: { shop } });
7924
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
7925
+ const owned = await merchantOrganizations({ read, user: record.user });
7926
+ const org = (owned || []).find((entry) => entry.id === organization2);
7927
+ if (!org) throw refusal("No access to this organization", 403);
7928
+ if (!CONNECTABLE_STATUSES.includes(org.billingStatus)) {
7929
+ throw refusal("This organization can't accept connections right now \u2014 check its billing status in the dashboard", 403);
7930
+ }
7931
+ if (org.connection && org.connection.shop !== shop) {
7932
+ throw refusal("This organization is already connected to another Shopify store", 409);
7933
+ }
7934
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7935
+ if (!(settings == null ? void 0 : settings.accessToken)) {
7936
+ throw refusal("Store is not installed \u2014 open the app in the Shopify admin first", 409);
7937
+ }
7938
+ const plan = await storePlan({ record, shopify });
7939
+ if (plan.unavailable) throw refusal("Couldn't verify the store's plan \u2014 try again shortly", 502);
7940
+ if (!plan.active) throw refusal("Approve a plan before connecting organizations", 402);
7941
+ let storefront = null;
7942
+ if (!record.storefront) {
7943
+ try {
7944
+ const minted2 = await shopify.oauth.createStorefrontToken({
7945
+ adminAccessToken: settings.accessToken,
7946
+ shop
7947
+ });
7948
+ if (minted2) storefront = encrypt({ token: minted2 });
7949
+ } catch (_) {
7950
+ }
7951
+ }
7952
+ let currency = (record == null ? void 0 : record.currency) || null;
7953
+ let source = (record == null ? void 0 : record.source) || null;
7954
+ try {
7955
+ const shopData = await shopify.oauth.getShop({ adminAccessToken: settings.accessToken, shop });
7956
+ if (shopData) {
7957
+ currency = String(shopData.currency || "").toLowerCase() || null;
7958
+ source = { domain: shopData.myshopifyDomain, id: String(shopData.id), label: shopData.name };
7959
+ }
7960
+ } catch (_) {
7961
+ }
7962
+ if (currency && currencies2 && !currencies2.includes(currency)) {
7963
+ throw refusal("This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.", 422);
7964
+ }
7965
+ const minted = ((_a = org.connection) == null ? void 0 : _a.id) ? null : mintId();
7966
+ const connectionId = ((_b = org.connection) == null ? void 0 : _b.id) || minted.id;
7967
+ return {
7968
+ message: "Connected " + org.name + " to " + shop + ".",
7969
+ request: { organization: organization2, shop },
7970
+ result: { ok: true },
7971
+ transaction: true,
7972
+ writes: [
7973
+ ...storefront ? [{
7974
+ collection: "shop",
7975
+ data: { $set: { storefront } },
7976
+ operation: "update",
7977
+ query: { shop }
7978
+ }] : [],
7979
+ {
7980
+ collection: "connection",
7981
+ data: {
7982
+ $set: {
7983
+ currency,
7984
+ errors: [],
7985
+ // The token of record lives on the shared `shop` row; this
7986
+ // is the pointer that satisfies the required `settings`
7987
+ // string, and `auth.install.shared` declares how to read it.
7988
+ settings: encrypt({ ref: "shop", shop }),
7989
+ shop,
7990
+ status: "pending",
7991
+ ...source && { source }
7992
+ },
7993
+ $setOnInsert: {
7994
+ ...minted && { _id: minted._id, id: minted.id },
7995
+ feature: "organization:connection:shopify",
7996
+ organization: organization2,
7997
+ slug: "shopify"
7998
+ }
7999
+ },
8000
+ operation: "update",
8001
+ options: { upsert: true },
8002
+ query: { organization: organization2, slug: "shopify" }
8003
+ },
8004
+ {
8005
+ collection: "organization",
8006
+ data: {
8007
+ // $addToSet UNCONDITIONALLY. The route this replaces only
8008
+ // registered the refs when the upsert INSERTED, which meant an
8009
+ // org whose refs had drifted stayed drifted through every
8010
+ // re-link. Adding a member that is already there is a no-op,
8011
+ // so doing it every time costs nothing and repairs the drift.
8012
+ $addToSet: {
8013
+ "connections.groups": "ecommerce",
8014
+ "connections.ids": connectionId,
8015
+ "connections.keys": "shopify"
8016
+ },
8017
+ $set: { billingProvider: "shopify" }
8018
+ },
8019
+ operation: "update",
8020
+ query: { id: organization2 }
8021
+ }
8022
+ ]
8023
+ };
8024
+ },
8025
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
8026
+ // store's live plan, and the products the merchant has to act on.
8027
+ //
8028
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
8029
+ // `add` would refuse it — a list that offers what the next call rejects
8030
+ // is worse than a shorter one.
8031
+ list: async ({ context }, { conversionRate: conversionRate2, planTitle, read, shopify = client } = {}) => {
8032
+ var _a;
8033
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8034
+ if (!shop) throw refusal("shop is required", 400);
8035
+ const record = await read.get({ collection: "shop", query: { shop } });
8036
+ if (!(record == null ? void 0 : record.user)) {
8037
+ return {
8038
+ message: "No account linked to this store.",
8039
+ request: { shop },
8040
+ result: {
8041
+ account: null,
8042
+ connected: false,
8043
+ organizations: [],
8044
+ plan: { active: false, name: null },
8045
+ unavailableProducts: [],
8046
+ unavailableProductsTotal: 0
8047
+ }
8048
+ };
8049
+ }
8050
+ const owned = await merchantOrganizations({ read, user: record.user });
8051
+ const organizations = (owned || []).filter((org) => !org.connection || org.connection.shop === shop).map((org) => {
8052
+ var _a2;
8053
+ return {
8054
+ // The org's OWN billing status decides whether `add` accepts it.
8055
+ // Sent up so the app can disable Connect and name the reason
8056
+ // rather than letting the merchant click into a 403 —
8057
+ // listed-but-blocked beats hidden, because an org vanishing from
8058
+ // the list is indistinguishable from a bug.
8059
+ billingStatus: org.billingStatus || null,
8060
+ connectable: CONNECTABLE_STATUSES.includes(org.billingStatus),
8061
+ connected: Boolean(org.connection),
8062
+ conversion: conversionRate2(org.subscription),
8063
+ id: org.id,
8064
+ name: org.name,
8065
+ plan: planTitle(org.subscription),
8066
+ status: ((_a2 = org.connection) == null ? void 0 : _a2.status) || null
8067
+ };
8068
+ });
8069
+ const connections2 = (owned || []).filter((org) => {
8070
+ var _a2;
8071
+ return ((_a2 = org.connection) == null ? void 0 : _a2.shop) === shop && org.connection.id;
8072
+ }).map((org) => org.connection.id);
8073
+ const [account, plan, unavailable] = await Promise.all([
8074
+ read.get({ collection: "user", query: { id: record.user } }),
8075
+ storePlan({ record, shopify }),
8076
+ connections2.length ? read.aggregate({
8077
+ collection: "product",
8078
+ pipeline: [
8079
+ { $match: { connections: { $in: connections2 }, "source.domain": shop, status: "inactive" } },
8080
+ // $facet so the app can name the first 25 AND report a
8081
+ // truthful total: listing 25 of 40 under "25 products need
8082
+ // attention" would understate the problem.
8083
+ {
8084
+ $facet: {
8085
+ items: [{ $sort: { title: 1 } }, { $limit: 25 }, { $project: { _id: 0, id: 1, title: 1 } }],
8086
+ total: [{ $count: "count" }]
8087
+ }
8088
+ }
8089
+ ]
8090
+ }) : []
8091
+ ]);
8092
+ const facet = (unavailable || [])[0] || {};
8093
+ if (plan.active && plan.metered !== true) {
8094
+ const stamped = await read.aggregate({
8095
+ collection: "connection",
8096
+ pipeline: [
8097
+ { $match: { shop, slug: "shopify" } },
8098
+ { $project: { _id: 0, "source.metered": 1 } }
8099
+ ]
8100
+ });
8101
+ const verdicts = (stamped || []).map((row2) => {
8102
+ var _a2;
8103
+ return (_a2 = row2 == null ? void 0 : row2.source) == null ? void 0 : _a2.metered;
8104
+ });
8105
+ if (verdicts.some((verdict) => typeof verdict === "string")) plan.metered = true;
8106
+ else if (verdicts.some((verdict) => verdict === null)) plan.metered = false;
8107
+ }
8108
+ return {
8109
+ message: organizations.length + " organization(s) for this store.",
8110
+ request: { shop },
8111
+ result: {
8112
+ account: { email: (account == null ? void 0 : account.email) || null },
8113
+ connected: true,
8114
+ organizations,
8115
+ plan,
8116
+ unavailableProducts: (facet.items || []).map((product) => ({
8117
+ id: product.id,
8118
+ title: product.title || "Untitled product"
8119
+ })),
8120
+ // The total can exceed the 25 listed above, and has to.
8121
+ unavailableProductsTotal: ((_a = (facet.total || [])[0]) == null ? void 0 : _a.count) || 0
8122
+ }
8123
+ };
8124
+ },
8125
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
8126
+ // else: every dependent — products, ads, steps, workflows, and the org's
8127
+ // own connection refs — is torn down by drawbridge-sync's connection
8128
+ // change stream. The shared token and the other orgs are untouched.
8129
+ remove: async ({ context }, { read } = {}) => {
8130
+ const organization2 = context == null ? void 0 : context.organization;
8131
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8132
+ if (!shop) throw refusal("shop is required", 400);
8133
+ if (!organization2) throw refusal("organization is required", 400);
8134
+ const record = await read.get({ collection: "shop", query: { shop } });
8135
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8136
+ const owned = await merchantOrganizations({ read, user: record.user });
8137
+ if (!(owned || []).some((entry) => entry.id === organization2)) {
8138
+ throw refusal("No access to this organization", 403);
8139
+ }
8140
+ const connection2 = await read.get({
8141
+ collection: "connection",
8142
+ query: { organization: organization2, shop, slug: "shopify" }
8143
+ });
8144
+ if (!connection2) throw refusal("No connection to remove", 404);
8145
+ return {
8146
+ message: "Disconnected.",
8147
+ request: { organization: organization2, shop },
8148
+ result: { ok: true },
8149
+ writes: [{ collection: "connection", operation: "delete", query: { id: connection2.id } }]
8150
+ };
8151
+ }
8152
+ },
8153
+ plan: {
8154
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
8155
+ // and falls back to "Choose a plan".
8156
+ cancel: ({ context }, { read, shopify = client } = {}) => releaseApprovals({ context, read, shopify }),
8157
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
8158
+ // order step and run on a retrying queue, so this may be called
8159
+ // several times for one order — which is safe because the idempotency
8160
+ // key is PERMANENT for billing events and Shopify settles the repeat.
8161
+ //
8162
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
8163
+ // is a real strengthening of what it replaced: drawbridge-sync compared
8164
+ // the job's `handle` against the job's `transaction` — two fields from
8165
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
8166
+ // handle is the one string that decides whether the event bills at all,
8167
+ // and this is the file that publishes it.
8168
+ //
8169
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
8170
+ // idempotency key on an event that misclassifies or mistraces. The
8171
+ // throw rides the queue's normal retry path and the abandoned-job alert
8172
+ // pages on exhaustion.
8173
+ //
8174
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
8175
+ // which is always inside the merchant's current billing cycle at the
8176
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
8177
+ // purchase time reads more correct and is strictly worse: a retry
8178
+ // backoff crossing a cycle boundary would carry a closed-cycle
8179
+ // timestamp, Shopify would 202-accept, silently drop it, and the
8180
+ // commission would be lost. Send-time's worst case is benign — the
8181
+ // charge lands in the next cycle, but it lands.
8182
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify = client } = {}) => {
8183
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
8184
+ const handle = (context == null ? void 0 : context.handle) || manifest.auth.install.billing.handle;
8185
+ if (!transaction || !String(transaction).startsWith(handle + ".")) {
8186
+ throw new Error("shopify.usage.billing.handle.mismatch on order " + orderId + ": transaction " + (transaction || "null") + " does not carry handle " + handle + " \u2014 refusing to send");
8187
+ }
8188
+ await shopify.billing.sendAppEvent({
8189
+ clientId,
8190
+ clientSecret,
8191
+ eventHandle: handle,
8192
+ idempotencyKey,
8193
+ // The Shopify-side event record names the order that produced it.
8194
+ reference: transaction,
8195
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
8196
+ // send. `revision` suffixes the idempotency key, which is what
8197
+ // makes a second attempt possible once a key has been consumed;
8198
+ // the encoding is owned by sendAppEvent so no caller builds keys
8199
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
8200
+ // path must never do — see the note above — and the recovery
8201
+ // migration must, because it is resending orders from a closed
8202
+ // window and bounds its own run to the merchant's current cycle.
8203
+ ...revision && { revision },
8204
+ ...timestamp && { timestamp },
8205
+ shopId,
8206
+ value
8207
+ });
8208
+ return {
8209
+ message: "Usage charge sent for order " + orderId + ".",
8210
+ request: { order: order || null, orderId: orderId || null, shopId: shopId || null, value },
8211
+ result: { transaction: String(transaction) },
8212
+ // TRACEABILITY. The App Events API returns no event id, so the
8213
+ // trace key into Shopify's event logs is the transaction id the
8214
+ // commerce.order hook minted and this sent as `reference`.
8215
+ // `amount` is the per-order usage value in cents, mirroring the
8216
+ // Stripe path's marker.
8217
+ ...order && { writes: [{
8218
+ collection: "order",
8219
+ data: {
8220
+ $set: {
8221
+ billed: {
8222
+ amount: value,
8223
+ date: /* @__PURE__ */ new Date(),
8224
+ rate: rate || 0,
8225
+ transaction: String(transaction)
8226
+ }
8227
+ }
8228
+ },
8229
+ operation: "update",
8230
+ query: { id: order }
8231
+ }] }
8232
+ };
8233
+ },
8234
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
8235
+ // be released before the pricing page will offer the plan again — but
8236
+ // the intent is a thirty-second round trip rather than a departure.
8237
+ //
8238
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
8239
+ // needs one. The marker has to be persisted BEFORE the approval is
8240
+ // released: it is what tells sync's CANCELLED handler to hold the pause
8241
+ // — no connection error, no owner email — and start a timed recheck that
8242
+ // pauses honestly if the merchant abandons. A described write lands
8243
+ // after the hook returns, which is after the cancel, and a marker
8244
+ // written then can race the very webhook it exists to soften. Same
8245
+ // precedent as `resolveContact`: the one write a description cannot
8246
+ // carry.
8247
+ reselect: async ({ context }, { mark, read, shopify = client } = {}) => {
8248
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8249
+ if (!shop) throw refusal("shop is required", 400);
8250
+ await mark({ shop });
8251
+ return releaseApprovals({ context, read, shopify });
8252
+ },
8253
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
8254
+ // action — the other two slots here are — but the same subject, and
8255
+ // the reason it is a declared hook rather than a helper is that the
8256
+ // answer was being derived in two places: `lifecycle.health` below,
8257
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
8258
+ // stated in its own comment that it matched this one and did not.
8259
+ //
8260
+ // THREE ANSWERS, and the caller must keep them apart:
8261
+ //
8262
+ // a string PROVEN. The charge id or usage line id, stamped on
8263
+ // the connection as the evidence.
8264
+ // null PROBED, and nothing is accruing.
8265
+ // undefined NO VERDICT — the probe could not run. `probed` says
8266
+ // which, because undefined does not survive JSON.
8267
+ verify: async ({ context }, { adminToken, read, shopify = client } = {}) => {
8268
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8269
+ if (!shop) throw refusal("shop is required", 400);
8270
+ const adminAccessToken = await adminToken();
8271
+ if (!adminAccessToken) {
8272
+ return { message: "No admin token for this store \u2014 no verdict.", request: { shop }, result: { metered: null, probed: false }, skipped: true };
8273
+ }
8274
+ const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
8275
+ const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
8276
+ const { ledger, metered } = await meterVerdict({
8277
+ adminAccessToken,
8278
+ ledgerFloor: new Date(Date.now() - LEDGER_WINDOW_MS),
8279
+ partner,
8280
+ shop,
8281
+ shopId: (context == null ? void 0 : context.shopId) || null,
8282
+ shopify
8283
+ });
8284
+ return {
8285
+ message: typeof metered === "string" ? "Billing is metered." : "No meter evidence for this store.",
8286
+ request: { shop },
8287
+ // `probed` is the LEDGER, not the call: a store whose Partner
8288
+ // credentials are absent was not asked, and reporting that as
8289
+ // "probed, nothing accruing" would stamp an unmetered verdict on
8290
+ // a store nobody looked at.
8291
+ result: { metered: metered ?? null, probed: ledger || typeof metered === "string" }
8292
+ };
8293
+ }
8294
+ },
8295
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
8296
+ // persists the offline session on token exchange and refresh; it lives
8297
+ // encrypted on the shared `shop` record, which is the same row every
8298
+ // organization's connection to this store points at.
8299
+ session: {
8300
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
8301
+ // invalidation and not only on uninstall, so hard-deleting the record
8302
+ // would orphan every org connection pointing at it — token resolution
8303
+ // silently nulls — and destroy the durable account link and the
8304
+ // storefront token with it. The next embedded load re-runs token
8305
+ // exchange. The full record delete belongs to the uninstall webhook.
8306
+ clear: async ({ context }) => {
8307
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8308
+ if (!shop) throw refusal("shop is required", 400);
8309
+ return {
8310
+ message: "Session cleared.",
8311
+ request: { shop },
8312
+ result: { ok: true },
8313
+ writes: [{
8314
+ collection: "shop",
8315
+ data: { $set: { settings: null } },
8316
+ operation: "update",
8317
+ query: { shop }
8318
+ }]
8319
+ };
8320
+ },
8321
+ read: async ({ context }, { read } = {}) => {
8322
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8323
+ if (!shop) throw refusal("shop is required", 400);
8324
+ const record = await read.get({ collection: "shop", query: { shop } });
8325
+ if (!(record == null ? void 0 : record.settings)) throw refusal("No session for this store", 404);
8326
+ return {
8327
+ message: "Session read.",
8328
+ request: { shop },
8329
+ result: { session: decrypt(record.settings) }
8330
+ };
8331
+ },
8332
+ save: async ({ context }) => {
8333
+ const session = context == null ? void 0 : context.session;
8334
+ const shop = sanitizeDomain(session == null ? void 0 : session.shop);
8335
+ if (!shop || !(session == null ? void 0 : session.accessToken)) throw refusal("session.shop and session.accessToken are required", 400);
8336
+ return {
8337
+ message: "Session saved.",
8338
+ request: { shop },
8339
+ result: { ok: true },
8340
+ writes: [{
8341
+ collection: "shop",
8342
+ data: {
8343
+ $set: { settings: encrypt(session), shop },
8344
+ $setOnInsert: { status: "pending" }
8345
+ },
8346
+ operation: "update",
8347
+ options: { upsert: true },
8348
+ query: { shop }
8349
+ }]
8350
+ };
8351
+ }
8352
+ },
8353
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
8354
+ // a first-party cookie; the embedded App Home cannot read that cookie from
8355
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
8356
+ // user token here keyed by shop, and App Home reads it once and clears it.
8357
+ //
8358
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
8359
+ // mid-handoff. Short-lived and encrypted.
8360
+ token: {
8361
+ clear: async ({ context }) => {
8362
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8363
+ if (!shop) throw refusal("shop is required", 400);
8364
+ return {
8365
+ message: "Link token cleared.",
8366
+ request: { shop },
8367
+ result: { ok: true },
8368
+ writes: [{
8369
+ collection: "shop",
8370
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null } },
8371
+ operation: "update",
8372
+ query: { shop }
8373
+ }]
8374
+ };
8375
+ },
8376
+ read: async ({ context }, { read } = {}) => {
8377
+ var _a;
8378
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8379
+ if (!shop) throw refusal("shop is required", 400);
8380
+ const record = await read.get({ collection: "shop", query: { shop } });
8381
+ const expiresAt = (record == null ? void 0 : record.linkTokenExpiresAt) ? new Date(record.linkTokenExpiresAt) : null;
8382
+ if (!(record == null ? void 0 : record.linkToken) || !expiresAt || expiresAt <= /* @__PURE__ */ new Date()) {
8383
+ throw refusal("No link token for this store", 404);
8384
+ }
8385
+ return {
8386
+ message: "Link token read.",
8387
+ request: { shop },
8388
+ result: { token: ((_a = decrypt(record.linkToken)) == null ? void 0 : _a.token) ?? null }
8389
+ };
8390
+ },
8391
+ save: async ({ context }) => {
8392
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8393
+ const token = context == null ? void 0 : context.token;
8394
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8395
+ return {
8396
+ message: "Link token saved.",
8397
+ request: { shop },
8398
+ result: { ok: true },
8399
+ writes: [{
8400
+ collection: "shop",
8401
+ data: {
8402
+ $set: {
8403
+ linkToken: encrypt({ token }),
8404
+ linkTokenExpiresAt: new Date(Date.now() + LINK_TOKEN_TTL_MS)
8405
+ }
8406
+ },
8407
+ operation: "update",
8408
+ query: { shop }
8409
+ }]
8410
+ };
8411
+ }
8412
+ }
8413
+ },
5694
8414
  // Drawbridge sends its own notification email and SMS — see the private
5695
8415
  // `drawbridge` manifest. A vendor answering these would be a second sender,
5696
8416
  // which is the arrangement the platform sender replaced.
@@ -5752,8 +8472,8 @@ var shopify_default2 = {
5752
8472
  // access token still works, reconciles the scopes the store granted
5753
8473
  // against the ones the app now needs, and queues a webhook
5754
8474
  // reconciliation.
5755
- health: async ({ connection: connection2, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
5756
- var _a, _b, _c, _d, _e, _f;
8475
+ health: async ({ connection: connection2, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {}) => {
8476
+ var _a, _b, _c, _d;
5757
8477
  const request2 = {
5758
8478
  connectionId: workflow.connection,
5759
8479
  organizationId: workflow.organization,
@@ -5790,33 +8510,26 @@ var shopify_default2 = {
5790
8510
  shopCurrency = String(shopData.currency || "").toLowerCase() || null;
5791
8511
  sourceRepaired = true;
5792
8512
  }
5793
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
5794
- adminAccessToken,
5795
- domain: connection2.shop
5796
- });
5797
- const usageLine = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
5798
- if (usageLine) metered = usageLine;
5799
8513
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
5800
8514
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
5801
- if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
5802
- const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
5803
- const accruals = await shopify.partner.getUsageChargeEvents({
5804
- appId: "gid://shopify/App/" + partner.partnerAppId,
5805
- first: 10,
5806
- occurredAtMin: ledgerFloor.toISOString(),
5807
- organizationId: partner.partnerOrgId,
5808
- partnerToken: partner.partnerToken,
5809
- shopId: "gid://shopify/Shop/" + source.id
5810
- });
5811
- const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
5812
- const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
5813
- if (accrued) metered = String(accrued.chargeId || accrued.id);
5814
- const stampedAt = (_c = boundaries.find(
8515
+ const ledgerFloor = new Date(Date.now() - LEDGER_WINDOW_MS);
8516
+ const verdict = await meterVerdict({
8517
+ adminAccessToken,
8518
+ ledgerFloor,
8519
+ partner,
8520
+ shop: connection2.shop,
8521
+ shopId: source == null ? void 0 : source.id,
8522
+ shopify
8523
+ });
8524
+ if (verdict.metered !== void 0) metered = verdict.metered;
8525
+ const { boundaries } = verdict;
8526
+ if (boundaries.length) {
8527
+ const stampedAt = (_a = boundaries.find(
5815
8528
  (event) => {
5816
8529
  var _a2;
5817
8530
  return String(event.chargeId || event.id) === String(((_a2 = connection2.source) == null ? void 0 : _a2.metered) || "");
5818
8531
  }
5819
- )) == null ? void 0 : _c.occurredAt;
8532
+ )) == null ? void 0 : _a.occurredAt;
5820
8533
  reconciliation = [];
5821
8534
  let windowStart = ledgerFloor;
5822
8535
  for (const boundary of boundaries) {
@@ -5866,7 +8579,7 @@ var shopify_default2 = {
5866
8579
  }
5867
8580
  });
5868
8581
  } else {
5869
- (_d = logger == null ? void 0 : logger.error) == null ? void 0 : _d.call(logger, new Error(
8582
+ (_b = logger == null ? void 0 : logger.error) == null ? void 0 : _b.call(logger, new Error(
5870
8583
  "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"
5871
8584
  ), {
5872
8585
  extra: {
@@ -5885,13 +8598,13 @@ var shopify_default2 = {
5885
8598
  }
5886
8599
  } catch (probeError) {
5887
8600
  billingProbeFailed = true;
5888
- (_e = logger == null ? void 0 : logger.warn) == null ? void 0 : _e.call(logger, "shopify.health.billing.probe.failed", {
8601
+ (_c = logger == null ? void 0 : logger.warn) == null ? void 0 : _c.call(logger, "shopify.health.billing.probe.failed", {
5889
8602
  connectionId: connection2.id,
5890
8603
  message: probeError == null ? void 0 : probeError.message,
5891
8604
  shop: connection2.shop
5892
8605
  });
5893
8606
  }
5894
- if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection2.source) == null ? void 0 : _f.metered) ?? void 0)) {
8607
+ if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection2.source) == null ? void 0 : _d.metered) ?? void 0)) {
5895
8608
  writes.push({
5896
8609
  collection: "connection",
5897
8610
  data: {
@@ -6026,13 +8739,13 @@ var shopify_default2 = {
6026
8739
  // They are the same two questions every other vendor answers through
6027
8740
  // resources.*, so they answer them the same way now.
6028
8741
  //
6029
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
8742
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
6030
8743
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
6031
8744
  // and nothing else — no controller, no collection access. Resolving the
6032
8745
  // credential is the caller's job because it is Drawbridge's job: the
6033
8746
  // admin token refreshes and writes itself back, which is service work,
6034
8747
  // not vendor work.
6035
- products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
8748
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify = client } = {}) => {
6036
8749
  var _a, _b, _c, _d;
6037
8750
  const products = await shopify.storefront.getProducts({
6038
8751
  cursor,
@@ -6054,7 +8767,7 @@ var shopify_default2 = {
6054
8767
  }
6055
8768
  };
6056
8769
  },
6057
- promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
8770
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify = client } = {}) => {
6058
8771
  var _a, _b;
6059
8772
  const discounts = await shopify.admin.getDiscounts({
6060
8773
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -6559,14 +9272,15 @@ var webhook_default = {
6559
9272
  "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
6560
9273
  "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."
6561
9274
  ],
6562
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6563
- // The dashboard already carries generic reasons for every way an
6564
- // authorization can fail declined, expired state, a vendor we are not set
6565
- // up for and a sentence here is an ADDITION to those, for a vendor whose
6566
- // failures need saying differently. This one's do not.
9275
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
9276
+ // nothing is authorized against a third party, so neither `denied` nor
9277
+ // `invalid` has a state it could describe. The failures this connection
9278
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
9279
+ // those reach them through the connection's own errors rather than through
9280
+ // connect copy.
6567
9281
  //
6568
- // Answered rather than omitted, because `errors` existed on two of six
6569
- // manifests and there was no way to tell "nothing to add" from "nobody
9282
+ // `false` is the recorded decision, not silence: `errors` existed on two of
9283
+ // six manifests and there was no way to tell "nothing to add" from "nobody
6570
9284
  // wrote it".
6571
9285
  errors: false,
6572
9286
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",