@drawbridge/drawbridge-utils 0.0.176 → 0.0.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/admin-B9ZaLvan.d.cts +697 -0
  2. package/dist/admin-C3HtEM6h.d.ts +697 -0
  3. package/dist/billing-Bc4yo9XG.d.cts +175 -0
  4. package/dist/billing-mNsKflmQ.d.ts +175 -0
  5. package/dist/billing.d.cts +1 -1
  6. package/dist/billing.d.ts +1 -1
  7. package/dist/connections/index.cjs +3391 -271
  8. package/dist/connections/index.d.cts +13 -4
  9. package/dist/connections/index.d.ts +13 -4
  10. package/dist/connections/index.js +3388 -270
  11. package/dist/features.cjs +3080 -241
  12. package/dist/features.d.cts +13 -4
  13. package/dist/features.d.ts +13 -4
  14. package/dist/features.js +3120 -275
  15. package/dist/http.cjs +10 -1
  16. package/dist/http.d.cts +10 -1
  17. package/dist/http.d.ts +10 -1
  18. package/dist/http.js +10 -1
  19. package/dist/{index-qY18QITf.d.cts → index-C2rxasGZ.d.cts} +2670 -354
  20. package/dist/{index-B8JhYfvU.d.ts → index-DOYCXtd7.d.ts} +2670 -354
  21. package/dist/oauth/index.d.cts +1 -1
  22. package/dist/oauth/index.d.ts +1 -1
  23. package/dist/oauth-BJDh0sdM.d.cts +527 -0
  24. package/dist/oauth-DveZMLHx.d.ts +527 -0
  25. package/dist/partner-BOZltuh2.d.ts +94 -0
  26. package/dist/partner-ed2OfW1J.d.cts +94 -0
  27. package/dist/plans.cjs +3079 -240
  28. package/dist/plans.d.cts +13 -4
  29. package/dist/plans.d.ts +13 -4
  30. package/dist/plans.js +3120 -275
  31. package/dist/pricing.cjs +3112 -273
  32. package/dist/pricing.d.cts +13 -4
  33. package/dist/pricing.d.ts +13 -4
  34. package/dist/pricing.js +3117 -272
  35. package/dist/providers.cjs +3082 -262
  36. package/dist/providers.d.cts +12 -3
  37. package/dist/providers.d.ts +12 -3
  38. package/dist/providers.js +3086 -260
  39. package/dist/sendgrid.cjs +10 -1
  40. package/dist/sendgrid.js +10 -1
  41. package/dist/shopify/admin.cjs +562 -0
  42. package/dist/shopify/admin.d.cts +3 -0
  43. package/dist/shopify/admin.d.ts +3 -0
  44. package/dist/shopify/admin.js +528 -0
  45. package/dist/shopify/billing.cjs +166 -0
  46. package/dist/shopify/billing.d.cts +3 -0
  47. package/dist/shopify/billing.d.ts +3 -0
  48. package/dist/shopify/billing.js +140 -0
  49. package/dist/shopify/constants.cjs +63 -0
  50. package/dist/shopify/constants.d.cts +58 -0
  51. package/dist/shopify/constants.d.ts +58 -0
  52. package/dist/shopify/constants.js +32 -0
  53. package/dist/shopify/oauth.cjs +509 -0
  54. package/dist/shopify/oauth.d.cts +7 -0
  55. package/dist/shopify/oauth.d.ts +7 -0
  56. package/dist/shopify/oauth.js +466 -0
  57. package/dist/shopify/partner.cjs +156 -0
  58. package/dist/shopify/partner.d.cts +3 -0
  59. package/dist/shopify/partner.d.ts +3 -0
  60. package/dist/shopify/partner.js +130 -0
  61. package/dist/shopify/storefront.cjs +611 -0
  62. package/dist/shopify/storefront.d.cts +3 -0
  63. package/dist/shopify/storefront.d.ts +3 -0
  64. package/dist/shopify/storefront.js +576 -0
  65. package/dist/storefront-C8FKOGeD.d.cts +659 -0
  66. package/dist/storefront-DJFGLqPl.d.ts +659 -0
  67. package/dist/twilio.cjs +10 -1
  68. package/dist/twilio.js +10 -1
  69. package/package.json +98 -68
package/dist/providers.js CHANGED
@@ -1,3 +1,9 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
1
7
  // lib/connections/contract.js
2
8
  var HOOKS = Object.freeze({
3
9
  // Proving and holding the credential.
@@ -124,6 +130,10 @@ var HOOKS = Object.freeze({
124
130
  "code",
125
131
  // Create the buyer at the vendor, so an order can be attributed to them.
126
132
  "customer",
133
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
134
+ // campaign's product list is rendered rather than stored, because stock is
135
+ // the one product fact that is stale the instant it is written down.
136
+ "inventory",
127
137
  // An order arrived at the vendor: attribute it, record it, update totals.
128
138
  "order",
129
139
  // Pull product data across on a vendor update.
@@ -191,7 +201,19 @@ var HOOKS = Object.freeze({
191
201
  organizations: Object.freeze(["add", "list", "remove"]),
192
202
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
193
203
  // declares the plan they are being charged on.
194
- plan: Object.freeze(["cancel", "reselect"]),
204
+ //
205
+ // `verify` is the odd one of the three: not something a merchant does, but
206
+ // the question "is this store's billing actually metered right now". It is
207
+ // here because it is the same subject — and because the answer was being
208
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
209
+ // approval webhook, whose copy said in its own comment that it matched the
210
+ // other and did not.
211
+ // `charge` and `verify` are the two that are not merchant actions: one
212
+ // sends a usage charge to the vendor's meter, the other asks whether that
213
+ // meter is actually working. Both are here because both are the same
214
+ // subject — what the merchant pays the vendor — and both were being done
215
+ // from drawbridge-sync with a direct import of the vendor's client.
216
+ plan: Object.freeze(["cancel", "charge", "reselect", "verify"]),
195
217
  // The embedded app's own session, stored by us on the vendor's behalf.
196
218
  session: Object.freeze(["clear", "read", "save"]),
197
219
  // The one-time code that hands a merchant from the embedded app into
@@ -276,7 +298,7 @@ var ANSWER_KEYS = Object.freeze([
276
298
  "usage"
277
299
  ].sort());
278
300
  var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
279
- var INSTALL_SIGNATURE = Object.freeze({ options: ["read", "request"], props: ["context"] });
301
+ var INSTALL_SIGNATURE = Object.freeze({ options: ["read"], props: ["context"] });
280
302
  var LIFECYCLE_SIGNATURE = Object.freeze({ options: ["dispatch"], props: ["context"] });
281
303
  var RESOURCE_SIGNATURE = Object.freeze({
282
304
  options: ["fetcher", "shopify"],
@@ -293,15 +315,30 @@ var HOOK_SLOT_PROPS = Object.freeze({
293
315
  // the second bag would be silently discarded, which is exactly the
294
316
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
295
317
  "auth.token": false,
296
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
297
- // api/route/subdomain.js writes nothing, which is why it never became a hook
298
- // so its signature mirrors `code`, its nearest sibling, and moving the
299
- // route here is a deliberate widening of this line rather than a silent one.
300
- "commerce.cart": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
318
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
319
+ // not for an organization inside a workflow, so it takes no `connection` and
320
+ // no `step`. It reads the storefront with the store's own storefront token
321
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
322
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
323
+ // already carries them, because a credential is a fact of the run rather
324
+ // than a service.
325
+ "commerce.cart": Object.freeze({
326
+ options: ["fetcher", "shopify"],
327
+ props: ["clientId", "clientSecret", "context", "settings"]
328
+ }),
301
329
  "commerce.code": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
302
330
  "commerce.customer": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["connection", "context"] }),
331
+ "commerce.inventory": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context"] }),
303
332
  "commerce.order": Object.freeze({ options: ["conversionRate", "logger", "mintId", "read"], props: ["connection", "context"] }),
304
- "commerce.product": Object.freeze({ options: ["mintId", "read"], props: ["connection", "context", "workflow"] }),
333
+ // THREE OPERATIONS, one slot — record, sync, variants. `settings` and
334
+ // `adminToken` arrived with the pull half: the storefront read needs the
335
+ // store's storefront token and the product-feedback send needs its admin one,
336
+ // and both are resolved by the caller because minting either needs a
337
+ // controller.
338
+ "commerce.product": Object.freeze({
339
+ options: ["adminToken", "mintId", "read", "shopify"],
340
+ props: ["connection", "context", "settings", "workflow"]
341
+ }),
305
342
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
306
343
  // person, not the lead document. `remove` called it `email` and `sync` called
307
344
  // it `doc` on one vendor and `lead` on three — three names for two facts.
@@ -314,19 +351,58 @@ var HOOK_SLOT_PROPS = Object.freeze({
314
351
  "inbound.process": Object.freeze({ options: ["dispatch"], props: ["context"] }),
315
352
  "inbound.receive": Object.freeze({ options: [], props: ["channel", "event", "headers", "payload"] }),
316
353
  "inbound.verify": Object.freeze({ options: [], props: ["body", "channel", "headers", "secret", "url"] }),
317
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
318
- // routes in api/route/shopify-embedded.js become, and the signature is
319
- // deliberately narrow: `context` is the request's own body, `read` and
320
- // `request` are the only services an install surface has been shown to need.
321
- // A route that cannot be expressed inside this line is a finding to bring
322
- // back here, not a name to add at the call site.
354
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
355
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
356
+ // of them; the options are where they differ, and every difference below was
357
+ // forced by a line in those routes rather than chosen.
358
+ //
359
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
360
+ // token slots that write, the two clears, and both account slots answer from
361
+ // the shared install record alone.
362
+ //
363
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
364
+ // Drawbridge owns that the manifest must not:
365
+ //
366
+ // conversionRate, planTitle what Drawbridge charges this organization.
367
+ // The org list renders both, and importing the
368
+ // plan catalogue here is the cycle
369
+ // features -> connections -> shopify -> plans
370
+ // -> features that was just broken.
371
+ // currencies which settlement currencies Drawbridge can
372
+ // bill. A store outside them fails the
373
+ // connection validator INSIDE the transaction,
374
+ // which surfaces as an opaque 500, so `add`
375
+ // checks first and says so.
376
+ // mintId the connection's id, known before the write
377
+ // that creates it, because the organization's
378
+ // own refs have to name it in the same
379
+ // transaction.
380
+ // shopify the vendor. Four slots call the store: two
381
+ // read the live plan, two release it.
382
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
383
+ // `reselect` is the only slot with one. See its
384
+ // implementation for why the order is the point.
323
385
  "install.account.link": INSTALL_SIGNATURE,
324
386
  "install.account.unlink": INSTALL_SIGNATURE,
325
- "install.organizations.add": INSTALL_SIGNATURE,
326
- "install.organizations.list": INSTALL_SIGNATURE,
387
+ "install.organizations.add": Object.freeze({
388
+ options: ["currencies", "mintId", "read", "shopify"],
389
+ props: ["context"]
390
+ }),
391
+ "install.organizations.list": Object.freeze({
392
+ options: ["conversionRate", "planTitle", "read", "shopify"],
393
+ props: ["context"]
394
+ }),
327
395
  "install.organizations.remove": INSTALL_SIGNATURE,
328
- "install.plan.cancel": INSTALL_SIGNATURE,
329
- "install.plan.reselect": INSTALL_SIGNATURE,
396
+ "install.plan.cancel": Object.freeze({ options: ["read", "shopify"], props: ["context"] }),
397
+ // `manifest` because the handle this charge bills under is the vendor's own
398
+ // declaration (auth.install.billing.handle), and checking the job against the
399
+ // declaration is what the enqueuer-checks-itself version could not do.
400
+ "install.plan.charge": Object.freeze({
401
+ options: ["shopify"],
402
+ props: ["clientId", "clientSecret", "context", "manifest"]
403
+ }),
404
+ "install.plan.reselect": Object.freeze({ options: ["mark", "read", "shopify"], props: ["context"] }),
405
+ "install.plan.verify": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["context"] }),
330
406
  "install.session.clear": INSTALL_SIGNATURE,
331
407
  "install.session.read": INSTALL_SIGNATURE,
332
408
  "install.session.save": INSTALL_SIGNATURE,
@@ -377,6 +453,16 @@ var TRIGGERS = Object.freeze({
377
453
  "range.ended": Object.freeze({ event: "range.ended", type: "range" }),
378
454
  "range.started": Object.freeze({ event: "range.started", type: "range" }),
379
455
  "schedule.day": Object.freeze({ event: "day", type: "schedule" }),
456
+ // THE THREE SHOPIFY TRIGGERS, which were never in this vocabulary because the
457
+ // four workflows they fire were hand-written in drawbridge-sync's register job
458
+ // rather than provisioned from the manifest. `webhook` is a fourth trigger
459
+ // TYPE, and it is the honest name: these fire when the vendor calls us, which
460
+ // is neither an event Drawbridge raised nor a schedule it keeps.
461
+ "shopify.order.create": Object.freeze({ event: "shopify.order.create", type: "webhook" }),
462
+ "shopify.product.update": Object.freeze({ event: "shopify.product.update", type: "webhook" }),
463
+ // The token audit, dispatched programmatically by the register job when a
464
+ // store's grant is exchanged or rotated.
465
+ "shopify.token": Object.freeze({ event: "shopify.token", type: "event" }),
380
466
  "schedule.month": Object.freeze({ event: "month", type: "schedule" }),
381
467
  "schedule.week": Object.freeze({ event: "week", type: "schedule" }),
382
468
  "segment.contact.add": Object.freeze({ event: "segment.contact.add", type: "event" }),
@@ -429,7 +515,7 @@ var OUTCOMES = Object.freeze({
429
515
  unimplemented: "unimplemented",
430
516
  unsupported: "unsupported"
431
517
  });
432
- var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
518
+ var STATUSES = Object.freeze(["active", "error", "pending"]);
433
519
  var PLATFORM_CREDENTIALS = Object.freeze([
434
520
  // Seals a merchant's stored connection settings. The webhook connection is
435
521
  // gated on it because its whole credential is a secret WE mint and store, so
@@ -485,6 +571,52 @@ var ERROR_SOURCES = Object.freeze({
485
571
  "shopify.register.webhooks": true
486
572
  });
487
573
 
574
+ // lib/encrypt.js
575
+ import crypto2 from "crypto";
576
+
577
+ // lib/token.js
578
+ import crypto from "crypto";
579
+ var generate = (bytes = 32, encoding = "base64url") => {
580
+ const buf = crypto.randomBytes(bytes);
581
+ return encoding ? buf.toString(encoding) : buf;
582
+ };
583
+ var hash = (value, key) => {
584
+ if (!value) return null;
585
+ if (key) {
586
+ return crypto.createHmac("sha256", key).update(String(value)).digest("base64url");
587
+ }
588
+ return crypto.createHash("sha256").update(String(value)).digest("base64url");
589
+ };
590
+
591
+ // lib/encrypt.js
592
+ var ALGORITHM = "aes-256-gcm";
593
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
594
+ var encrypt = (value) => {
595
+ const iv = generate(12, null);
596
+ const cipher = crypto2.createCipheriv(ALGORITHM, getKey(), iv);
597
+ const data = Buffer.concat([
598
+ cipher.update(JSON.stringify(value), "utf8"),
599
+ cipher.final()
600
+ ]);
601
+ const tag = cipher.getAuthTag();
602
+ return [iv, tag, data].map((b) => b.toString("hex")).join(":");
603
+ };
604
+ var decrypt = (value) => {
605
+ if (typeof value !== "string") return value;
606
+ const [ivHex, tagHex, dataHex] = value.split(":");
607
+ const decipher = crypto2.createDecipheriv(
608
+ ALGORITHM,
609
+ getKey(),
610
+ Buffer.from(ivHex, "hex")
611
+ );
612
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
613
+ const result = Buffer.concat([
614
+ decipher.update(Buffer.from(dataHex, "hex")),
615
+ decipher.final()
616
+ ]);
617
+ return JSON.parse(result.toString("utf8"));
618
+ };
619
+
488
620
  // lib/connections/oauth.js
489
621
  import { createHash, randomBytes } from "crypto";
490
622
  var credentials = ({ basic, clientId, clientSecret }) => basic ? {
@@ -511,10 +643,10 @@ var authToken = async ({
511
643
  const renewing = !code;
512
644
  if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
513
645
  if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
514
- const client = credentials({ basic, clientId, clientSecret });
646
+ const client2 = credentials({ basic, clientId, clientSecret });
515
647
  const response = await fetcher(descriptor.endpoints.token, {
516
648
  body: new URLSearchParams({
517
- ...client.body,
649
+ ...client2.body,
518
650
  ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
519
651
  code: decodeURIComponent(String(code || "").trim()),
520
652
  grant_type: "authorization_code",
@@ -522,7 +654,7 @@ var authToken = async ({
522
654
  ...descriptor.pkce && { code_verifier: verifier }
523
655
  }
524
656
  }),
525
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
657
+ headers: { "content-type": "application/x-www-form-urlencoded", ...client2.headers },
526
658
  method: "POST",
527
659
  signal: AbortSignal.timeout(15e3)
528
660
  });
@@ -917,9 +1049,18 @@ var scaffold_default = {
917
1049
  commerce: {
918
1050
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
919
1051
  // the last api surface Shopify owns outside a hook.
920
- cart: async ({ connection, context }, { read } = {}) => ANSWER,
1052
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
1053
+ // page, not for an organization inside a workflow. It reads the storefront
1054
+ // with the store's storefront token and mints a checkout token from
1055
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
1056
+ // as props — the way auth.disconnect already carries it.
1057
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify } = {}) => ANSWER,
921
1058
  code: async ({ connection, context, step }, { adminToken, shopify } = {}) => ANSWER,
922
1059
  customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => ANSWER,
1060
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
1061
+ // fact that is stale the instant it is written down, so the campaign
1062
+ // product list asks the vendor and caches the answer for a minute.
1063
+ inventory: async ({ connection, context }, { adminToken, shopify } = {}) => ANSWER,
923
1064
  order: async ({ connection, context }, { logger, mintId, read } = {}) => ANSWER,
924
1065
  // THIS IS WHERE `product.insert` LANDS.
925
1066
  //
@@ -941,7 +1082,7 @@ var scaffold_default = {
941
1082
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
942
1083
  // a log. Phase 1 closes it and build() checks every trigger against it.
943
1084
  product: {
944
- hook: async ({ connection, context, workflow }, { mintId, read } = {}) => ANSWER,
1085
+ hook: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify } = {}) => ANSWER,
945
1086
  step: () => ({
946
1087
  description: "Syncs product data from the vendor when a product changes.",
947
1088
  key: "Vendor Product Sync",
@@ -1089,8 +1230,19 @@ var scaffold_default = {
1089
1230
  // Vendor billing, the only group that is not generic. Pairs with
1090
1231
  // auth.install.billing, which declares what the merchant is being charged.
1091
1232
  plan: {
1092
- cancel: async ({ context }, { dispatch, read } = {}) => ANSWER,
1093
- reselect: async ({ context }, { dispatch, read } = {}) => ANSWER
1233
+ cancel: async ({ context }, { read, shopify } = {}) => ANSWER,
1234
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
1235
+ // sent to the vendor's meter. The credential pair rides in as props
1236
+ // because the charge authenticates as the APP, not as the store.
1237
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify } = {}) => ANSWER,
1238
+ // `mark` is the one write a description cannot carry: the marker must
1239
+ // be persisted BEFORE the approval is released, and a described write
1240
+ // lands after the hook returns.
1241
+ reselect: async ({ context }, { mark, read, shopify } = {}) => ANSWER,
1242
+ // NOT A MERCHANT ACTION — the question "is this store's billing
1243
+ // actually metered right now", asked by the daily health check and by
1244
+ // the vendor's own approval webhook, which used to hand-copy it.
1245
+ verify: async ({ context }, { adminToken, read, shopify } = {}) => ANSWER
1094
1246
  },
1095
1247
  // The embedded app's own session, stored by us on the vendor's behalf.
1096
1248
  session: {
@@ -1576,16 +1728,19 @@ var attentive_default2 = {
1576
1728
  "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.",
1577
1729
  "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."
1578
1730
  ],
1579
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
1580
- // The dashboard already carries generic reasons for every way an
1581
- // authorization can fail — declined, expired state, a vendor we are not set
1582
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
1583
- // failures need saying differently. This one's do not.
1731
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
1732
+ // consent screen has two ways to end: they said no, or something between
1733
+ // here and the vendor went wrong.
1584
1734
  //
1585
- // Answered rather than omitted, because `errors` existed on two of six
1586
- // manifests and there was no way to tell "nothing to add" from "nobody
1587
- // wrote it".
1588
- errors: false,
1735
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
1736
+ // authorization"; a merchant connecting two accounts in one sitting needs
1737
+ // to know which one this was.
1738
+ errors: {
1739
+ connect: {
1740
+ denied: "The Attentive authorization was declined, so nothing was connected.",
1741
+ invalid: "We couldn't complete the Attentive connection. Try connecting again."
1742
+ }
1743
+ },
1589
1744
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
1590
1745
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
1591
1746
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -2113,6 +2268,15 @@ var readEventHeader = ({ descriptor, headers }) => ({ result: { event: headers[d
2113
2268
  var DEFAULT_TIMEOUT_MS = 15e3;
2114
2269
  var request = async ({
2115
2270
  body,
2271
+ // THE TRANSPORT, injectable and defaulted to the real one.
2272
+ //
2273
+ // Every other vendor client in this package takes a `fetcher` — it is how
2274
+ // klaviyo, mailchimp and attentive are tested without a network, and it is a
2275
+ // declared HOOK_OPTION. The Shopify client had no seam at all, so the only way
2276
+ // to test a hook that used it was to inject the whole SDK namespace; that
2277
+ // injection existed to work around an import cycle, and when the cycle went
2278
+ // the tests lost their only hold. This is the seam they should have had.
2279
+ fetcher = fetch,
2116
2280
  headers = {},
2117
2281
  method = "GET",
2118
2282
  query,
@@ -2126,7 +2290,7 @@ var request = async ({
2126
2290
  }
2127
2291
  ;
2128
2292
  const isForm = type === "form";
2129
- const response = await fetch(fullUrl.toString(), {
2293
+ const response = await fetcher(fullUrl.toString(), {
2130
2294
  method,
2131
2295
  headers: {
2132
2296
  "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
@@ -2583,15 +2747,12 @@ var drawbridge_default2 = {
2583
2747
  description: [
2584
2748
  "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2585
2749
  ],
2586
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2587
- // The dashboard already carries generic reasons for every way an
2588
- // authorization can fail declined, expired state, a vendor we are not set
2589
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2590
- // failures need saying differently. This one's do not.
2750
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
2751
+ // own manifest always on, private, never in the catalogue — and it exists
2752
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
2753
+ // describe a consent screen this connection does not have.
2591
2754
  //
2592
- // Answered rather than omitted, because `errors` existed on two of six
2593
- // manifests and there was no way to tell "nothing to add" from "nobody
2594
- // wrote it".
2755
+ // `false` is the recorded decision, not silence.
2595
2756
  errors: false,
2596
2757
  excerpt: "The steps Drawbridge runs itself.",
2597
2758
  guide: [
@@ -3351,10 +3512,10 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3351
3512
 
3352
3513
  // lib/connections/scopes.js
3353
3514
  var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3354
- var missingScopes = ({ granted, required }) => {
3515
+ var missingScopes = ({ granted, required: required2 }) => {
3355
3516
  if (granted === null || granted === void 0 || granted === "") return null;
3356
3517
  const held = new Set(list(granted));
3357
- return list(required).filter((scope) => !held.has(scope));
3518
+ return list(required2).filter((scope) => !held.has(scope));
3358
3519
  };
3359
3520
 
3360
3521
  // lib/connections/manifests/klaviyo.js
@@ -4253,16 +4414,19 @@ var mailchimp_default2 = {
4253
4414
  "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.",
4254
4415
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
4255
4416
  ],
4256
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4257
- // The dashboard already carries generic reasons for every way an
4258
- // authorization can fail — declined, expired state, a vendor we are not set
4259
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4260
- // failures need saying differently. This one's do not.
4417
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
4418
+ // consent screen has two ways to end: they said no, or something between
4419
+ // here and the vendor went wrong.
4261
4420
  //
4262
- // Answered rather than omitted, because `errors` existed on two of six
4263
- // manifests and there was no way to tell "nothing to add" from "nobody
4264
- // wrote it".
4265
- errors: false,
4421
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
4422
+ // authorization"; a merchant connecting two accounts in one sitting needs
4423
+ // to know which one this was.
4424
+ errors: {
4425
+ connect: {
4426
+ denied: "The Mailchimp authorization was declined, so nothing was connected.",
4427
+ invalid: "We couldn't complete the Mailchimp connection. Try connecting again."
4428
+ }
4429
+ },
4266
4430
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
4267
4431
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
4268
4432
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -4361,7 +4525,7 @@ var mailchimp_default2 = {
4361
4525
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4362
4526
  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);
4363
4527
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4364
- const hash = subscriberHash(email);
4528
+ const hash2 = subscriberHash(email);
4365
4529
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4366
4530
  const lastName = restOfName.join(" ");
4367
4531
  const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
@@ -4370,7 +4534,7 @@ var mailchimp_default2 = {
4370
4534
  ...lastName && { LNAME: lastName },
4371
4535
  ...phone && { PHONE: phone }
4372
4536
  };
4373
- const member = await api3("/lists/" + audience + "/members/" + hash, {
4537
+ const member = await api3("/lists/" + audience + "/members/" + hash2, {
4374
4538
  dc: settings == null ? void 0 : settings.dc,
4375
4539
  fetcher,
4376
4540
  method: "PUT",
@@ -4411,7 +4575,7 @@ var mailchimp_default2 = {
4411
4575
  status: joined.has(entry.id) ? "active" : "inactive"
4412
4576
  }));
4413
4577
  if (tags.length > 0) {
4414
- await api3("/lists/" + audience + "/members/" + hash + "/tags", {
4578
+ await api3("/lists/" + audience + "/members/" + hash2 + "/tags", {
4415
4579
  dc: settings == null ? void 0 : settings.dc,
4416
4580
  fetcher,
4417
4581
  method: "POST",
@@ -4422,12 +4586,12 @@ var mailchimp_default2 = {
4422
4586
  }
4423
4587
  return {
4424
4588
  // Merged into `context` for later steps in this run.
4425
- context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
4589
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 },
4426
4590
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4427
4591
  request: request2,
4428
4592
  // Recorded on the run for support to read back, not a write
4429
4593
  // instruction — the hook has already written what it needed to.
4430
- response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
4594
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 }
4431
4595
  };
4432
4596
  }
4433
4597
  },
@@ -4797,158 +4961,2030 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
4797
4961
  <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"/>
4798
4962
  </svg>`;
4799
4963
 
4800
- // lib/email.js
4801
- var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
4802
- var toCanonicalEmail = (value) => {
4803
- if (!value || typeof value !== "string") return null;
4804
- const email = value.trim().toLowerCase();
4805
- const at = email.lastIndexOf("@");
4806
- if (at < 1 || at === email.length - 1) return null;
4807
- let local = email.slice(0, at);
4808
- const domain = email.slice(at + 1);
4809
- const plus = local.indexOf("+");
4810
- if (plus > 0) local = local.slice(0, plus);
4811
- if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
4812
- if (!local) return null;
4813
- return local + "@" + domain;
4814
- };
4815
-
4816
- // lib/encrypt.js
4817
- import crypto2 from "crypto";
4964
+ // lib/shopify/admin.js
4965
+ var admin_exports = {};
4966
+ __export(admin_exports, {
4967
+ adminFetch: () => adminFetch,
4968
+ createDiscountCode: () => createDiscountCode,
4969
+ createUsageRecord: () => createUsageRecord,
4970
+ createUsageSubscription: () => createUsageSubscription,
4971
+ getActiveAppSubscriptions: () => getActiveAppSubscriptions,
4972
+ getDiscounts: () => getDiscounts,
4973
+ getOrCreateCustomer: () => getOrCreateCustomer,
4974
+ getProductInventory: () => getProductInventory,
4975
+ sendProductResourceFeedback: () => sendProductResourceFeedback
4976
+ });
4818
4977
 
4819
- // lib/token.js
4820
- import crypto from "crypto";
4821
- var generate = (bytes = 32, encoding = "base64url") => {
4822
- const buf = crypto.randomBytes(bytes);
4823
- return encoding ? buf.toString(encoding) : buf;
4824
- };
4978
+ // lib/shopify/constants.js
4979
+ var SHOPIFY_STOREFRONT_API_VERSION = "2025-01";
4980
+ var SHOPIFY_ADMIN_API_VERSION = "2026-04";
4981
+ var SHOPIFY_APP_API_VERSION = "unstable";
4982
+ var SHOPIFY_REQUIRED_SCOPES = [
4983
+ "read_customers",
4984
+ "write_customers",
4985
+ "read_discounts",
4986
+ "write_discounts",
4987
+ "read_orders",
4988
+ "read_products",
4989
+ "read_publications",
4990
+ "write_resource_feedbacks",
4991
+ "unauthenticated_read_checkouts",
4992
+ "unauthenticated_read_product_inventory",
4993
+ "unauthenticated_read_product_listings",
4994
+ "unauthenticated_write_checkouts"
4995
+ ];
4996
+ var REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1e3;
4997
+ var ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
4998
+ var PARTNER_API_VERSION = process.env.SHOPIFY_PARTNER_API_VERSION || "2026-07";
4825
4999
 
4826
- // lib/encrypt.js
4827
- var ALGORITHM = "aes-256-gcm";
4828
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
4829
- var encrypt = (value) => {
4830
- const iv = generate(12, null);
4831
- const cipher = crypto2.createCipheriv(ALGORITHM, getKey(), iv);
4832
- const data = Buffer.concat([
4833
- cipher.update(JSON.stringify(value), "utf8"),
4834
- cipher.final()
4835
- ]);
4836
- const tag = cipher.getAuthTag();
4837
- return [iv, tag, data].map((b) => b.toString("hex")).join(":");
5000
+ // lib/shopify/admin.js
5001
+ var adminUrl = (domain) => `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}`;
5002
+ var adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
5003
+ var _a;
5004
+ const { data, errors } = await request({
5005
+ fetcher,
5006
+ method: "POST",
5007
+ url: adminUrl(domain) + "/graphql.json",
5008
+ headers: {
5009
+ "X-Shopify-Access-Token": adminAccessToken
5010
+ },
5011
+ body: {
5012
+ query,
5013
+ ...variables && { variables }
5014
+ }
5015
+ });
5016
+ if (errors) {
5017
+ throw new Error("Shopify GraphQL error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
5018
+ }
5019
+ ;
5020
+ return data;
4838
5021
  };
4839
- var decrypt = (value) => {
4840
- if (typeof value !== "string") return value;
4841
- const [ivHex, tagHex, dataHex] = value.split(":");
4842
- const decipher = crypto2.createDecipheriv(
4843
- ALGORITHM,
4844
- getKey(),
4845
- Buffer.from(ivHex, "hex")
4846
- );
4847
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
4848
- const result = Buffer.concat([
4849
- decipher.update(Buffer.from(dataHex, "hex")),
4850
- decipher.final()
4851
- ]);
4852
- return JSON.parse(result.toString("utf8"));
5022
+ var DISCOUNT_FIELDS = `
5023
+ title
5024
+ status
5025
+ startsAt
5026
+ endsAt
5027
+ usageLimit
5028
+ asyncUsageCount
5029
+ appliesOncePerCustomer
5030
+ context { __typename }
5031
+ `;
5032
+ var discountsQuery = (search, cursor, limit) => `{
5033
+ codeDiscountNodes(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}) {
5034
+ edges {
5035
+ node {
5036
+ id
5037
+ codeDiscount {
5038
+ ... on DiscountCodeBasic { ${DISCOUNT_FIELDS} }
5039
+ ... on DiscountCodeBxgy { ${DISCOUNT_FIELDS} }
5040
+ ... on DiscountCodeFreeShipping { ${DISCOUNT_FIELDS} }
5041
+ ... on DiscountCodeApp { ${DISCOUNT_FIELDS} }
5042
+ }
5043
+ }
5044
+ }
5045
+ pageInfo {
5046
+ endCursor
5047
+ hasNextPage
5048
+ hasPreviousPage
5049
+ startCursor
5050
+ }
5051
+ }
5052
+ }`;
5053
+ var getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
5054
+ const data = await adminFetch({
5055
+ fetcher,
5056
+ adminAccessToken,
5057
+ domain,
5058
+ query: discountsQuery(search, cursor, limit)
5059
+ });
5060
+ return data == null ? void 0 : data.codeDiscountNodes;
4853
5061
  };
4854
-
4855
- // lib/slugify.js
4856
- import slug from "slugify";
4857
- var slugify = (value, nochars = false) => {
4858
- const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
4859
- const replacement = nochars ? "" : "-";
4860
- return slug(
4861
- value,
4862
- {
4863
- remove,
4864
- replacement,
4865
- lower: true,
4866
- trim: true
5062
+ var discountGid = (id) => String(id).startsWith("gid://") ? String(id) : "gid://shopify/DiscountCodeNode/" + id;
5063
+ var createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code }) => {
5064
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5065
+ const added = await adminFetch({
5066
+ fetcher,
5067
+ adminAccessToken,
5068
+ domain,
5069
+ query: `
5070
+ mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
5071
+ discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
5072
+ bulkCreation {
5073
+ id
5074
+ }
5075
+ userErrors {
5076
+ field
5077
+ message
5078
+ }
5079
+ }
5080
+ }
5081
+ `,
5082
+ variables: {
5083
+ codes: [{ code }],
5084
+ discountId: discountGid(discountId)
4867
5085
  }
4868
- );
4869
- };
4870
-
4871
- // lib/connections/manifests/shopify.js
4872
- var toLine = ({
4873
- price,
4874
- product_id: productId,
4875
- quantity,
4876
- title,
4877
- variant_id: variantId,
4878
- variant_title: variantTitle
4879
- }) => ({
4880
- price: parseFloat(price) || 0,
4881
- productId: productId ? "gid://shopify/Product/" + productId : null,
4882
- quantity: quantity || 1,
4883
- title: title || null,
4884
- variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
4885
- variantTitle: variantTitle || null
4886
- });
4887
- var attributeLineItems = (lineItems = []) => lineItems.reduce(
4888
- (acc, item) => {
4889
- const attrs = (item.properties || []).reduce(
4890
- (map, { name, value }) => {
4891
- map[name] = value;
4892
- return map;
4893
- },
4894
- {}
4895
- );
4896
- if (!attrs["_drwbrdg_ca"]) return acc;
4897
- if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
4898
- const line = toLine(item);
4899
- acc.attributedGross += line.price * line.quantity;
4900
- acc.attributedLines.push(line);
4901
- return acc;
4902
- },
4903
- { attrMap: {}, attributedGross: 0, attributedLines: [] }
4904
- );
4905
- var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4906
- var blockedReason = (discount) => {
4907
- var _a;
4908
- if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4909
- const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4910
- if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4911
- 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.";
5086
+ });
5087
+ const addErrors = (_a = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _a.userErrors;
5088
+ if (addErrors == null ? void 0 : addErrors.length) {
5089
+ throw new Error("Shopify discount code create failed: " + addErrors[0].message);
4912
5090
  }
4913
5091
  ;
4914
- if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4915
- return "This discount has reached its total usage limit.";
5092
+ const bulkCreationId = (_c = (_b = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _b.bulkCreation) == null ? void 0 : _c.id;
5093
+ if (!bulkCreationId) {
5094
+ throw new Error("Shopify discount code create failed: no bulk creation id returned");
4916
5095
  }
4917
5096
  ;
4918
- return null;
5097
+ for (let attempt = 0; attempt < 15; attempt++) {
5098
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5099
+ const polled = await adminFetch({
5100
+ fetcher,
5101
+ adminAccessToken,
5102
+ domain,
5103
+ query: `
5104
+ query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
5105
+ discountRedeemCodeBulkCreation(id: $id) {
5106
+ done
5107
+ codes(first: 1) {
5108
+ nodes {
5109
+ code
5110
+ discountRedeemCode {
5111
+ id
5112
+ }
5113
+ errors {
5114
+ message
5115
+ }
5116
+ }
5117
+ }
5118
+ }
5119
+ }
5120
+ `,
5121
+ variables: {
5122
+ id: bulkCreationId
5123
+ }
5124
+ });
5125
+ const creation = polled == null ? void 0 : polled.discountRedeemCodeBulkCreation;
5126
+ if (!(creation == null ? void 0 : creation.done)) continue;
5127
+ const node = (_e = (_d = creation.codes) == null ? void 0 : _d.nodes) == null ? void 0 : _e[0];
5128
+ if (!((_f = node == null ? void 0 : node.discountRedeemCode) == null ? void 0 : _f.id)) {
5129
+ 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"));
5130
+ }
5131
+ ;
5132
+ return {
5133
+ code: node.code,
5134
+ // Numeric tail keeps the stored shopifyDiscountId in the shape the
5135
+ // legacy REST endpoint returned — it feeds a merchant-visible
5136
+ // workflow template variable, so no gid:// leakage.
5137
+ id: node.discountRedeemCode.id.split("/").pop()
5138
+ };
5139
+ }
5140
+ ;
5141
+ throw new Error("Shopify discount code create timed out waiting for bulk creation " + bulkCreationId);
4919
5142
  };
4920
- var discountWarning = (discount) => {
4921
- if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4922
- return "This discount hasn't started yet, so codes issued before it does won't work until then.";
5143
+ var getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
5144
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5145
+ const lookupQuery = `
5146
+ query customerByEmail( $query : String! ) {
5147
+ customers( first : 1, query : $query ) {
5148
+ edges {
5149
+ node {
5150
+ id
5151
+ }
5152
+ }
5153
+ }
5154
+ }
5155
+ `;
5156
+ const data = await adminFetch({
5157
+ fetcher,
5158
+ adminAccessToken,
5159
+ domain,
5160
+ query: lookupQuery,
5161
+ variables: { query: "email:" + email }
5162
+ });
5163
+ const existing = (_c = (_b = (_a = data == null ? void 0 : data.customers) == null ? void 0 : _a.edges) == null ? void 0 : _b[0]) == null ? void 0 : _c.node;
5164
+ if (existing) return existing;
5165
+ const input = { email };
5166
+ if (firstName) input.firstName = firstName;
5167
+ if (lastName) input.lastName = lastName;
5168
+ if (source) {
5169
+ input.metafields = [{
5170
+ namespace: "drwbrdg",
5171
+ key: "source",
5172
+ type: "single_line_text_field",
5173
+ value: source
5174
+ }];
4923
5175
  }
4924
5176
  ;
4925
- if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4926
- return null;
5177
+ const result = await adminFetch({
5178
+ fetcher,
5179
+ adminAccessToken,
5180
+ domain,
5181
+ query: `
5182
+ mutation customerCreate( $input : CustomerInput! ) {
5183
+ customerCreate( input : $input ) {
5184
+ customer {
5185
+ id
5186
+ }
5187
+ userErrors {
5188
+ field
5189
+ message
5190
+ }
5191
+ }
5192
+ }
5193
+ `,
5194
+ variables: { input }
5195
+ });
5196
+ const userErrors = (_d = result == null ? void 0 : result.customerCreate) == null ? void 0 : _d.userErrors;
5197
+ if (userErrors == null ? void 0 : userErrors.length) {
5198
+ const emailTaken = userErrors.some((e) => {
5199
+ var _a2;
5200
+ return (_a2 = e.message) == null ? void 0 : _a2.includes("Email has already been taken");
5201
+ });
5202
+ if (emailTaken) {
5203
+ const retry = await adminFetch({
5204
+ fetcher,
5205
+ adminAccessToken,
5206
+ domain,
5207
+ query: lookupQuery,
5208
+ variables: { query: "email:" + email }
5209
+ });
5210
+ 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;
5211
+ if (found) return found;
5212
+ }
5213
+ ;
5214
+ throw new Error("Shopify customer create failed: " + userErrors[0].message);
5215
+ }
5216
+ ;
5217
+ return (_h = result == null ? void 0 : result.customerCreate) == null ? void 0 : _h.customer;
4927
5218
  };
4928
- var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
4929
- var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
4930
- var OAUTH_ERROR_SOURCE = "oauth";
4931
- var BILLING_ERROR_SOURCE = "billing";
4932
- var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
4933
- var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
4934
- var inbound = {
4935
- headers: {
4936
- event: "x-shopify-topic",
4937
- id: "x-shopify-webhook-id",
4938
- shop: "x-shopify-shop-domain",
4939
- signature: "x-shopify-hmac-sha256"
4940
- },
4941
- signature: {
4942
- algorithm: "sha256",
4943
- encoding: "base64",
4944
- secret: "SHOPIFY_API_SECRET"
5219
+ var getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
5220
+ var _a, _b;
5221
+ const data = await adminFetch({
5222
+ fetcher,
5223
+ adminAccessToken,
5224
+ domain,
5225
+ query: `{
5226
+ currentAppInstallation {
5227
+ activeSubscriptions {
5228
+ id
5229
+ name
5230
+ status
5231
+ test
5232
+ lineItems {
5233
+ id
5234
+ plan {
5235
+ pricingDetails {
5236
+ __typename
5237
+ }
5238
+ }
5239
+ }
5240
+ }
5241
+ }
5242
+ shop {
5243
+ myshopifyDomain
5244
+ }
5245
+ }`
5246
+ });
5247
+ return {
5248
+ shopDomain: ((_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.myshopifyDomain) || null,
5249
+ subscriptions: (((_b = data == null ? void 0 : data.currentAppInstallation) == null ? void 0 : _b.activeSubscriptions) || []).map((subscription) => {
5250
+ var _a2;
5251
+ return {
5252
+ ...subscription,
5253
+ metered: ((subscription == null ? void 0 : subscription.lineItems) || []).some(
5254
+ (item) => {
5255
+ var _a3, _b2;
5256
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5257
+ }
5258
+ ),
5259
+ // The usage line's own id — the address appUsageRecordCreate charges
5260
+ // against. Null when the approval carries no usage component.
5261
+ usageLineItemId: ((_a2 = ((subscription == null ? void 0 : subscription.lineItems) || []).find(
5262
+ (item) => {
5263
+ var _a3, _b2;
5264
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5265
+ }
5266
+ )) == null ? void 0 : _a2.id) || null
5267
+ };
5268
+ })
5269
+ };
5270
+ };
5271
+ var createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = "USD", domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
5272
+ if (!name || !returnUrl || !terms || !(Number(cappedAmount) > 0)) {
5273
+ throw new Error("createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount");
5274
+ }
5275
+ ;
5276
+ const data = await adminFetch({
5277
+ fetcher,
5278
+ adminAccessToken,
5279
+ domain,
5280
+ query: `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
5281
+ appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
5282
+ appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
5283
+ confirmationUrl
5284
+ userErrors { field message }
5285
+ }
5286
+ }`,
5287
+ variables: {
5288
+ lineItems: [
5289
+ {
5290
+ plan: {
5291
+ appRecurringPricingDetails: {
5292
+ interval: "EVERY_30_DAYS",
5293
+ price: { amount: Number(recurringPrice), currencyCode }
5294
+ }
5295
+ }
5296
+ },
5297
+ {
5298
+ plan: {
5299
+ appUsagePricingDetails: {
5300
+ cappedAmount: { amount: Number(cappedAmount), currencyCode },
5301
+ terms
5302
+ }
5303
+ }
5304
+ }
5305
+ ],
5306
+ name,
5307
+ returnUrl,
5308
+ test
5309
+ }
5310
+ });
5311
+ const payload = data == null ? void 0 : data.appSubscriptionCreate;
5312
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5313
+ if (userErrors.length) {
5314
+ throw new Error("Shopify subscription create failed: " + userErrors.map((entry) => entry.message).join("; "));
5315
+ }
5316
+ ;
5317
+ if (!(payload == null ? void 0 : payload.confirmationUrl)) {
5318
+ throw new Error("Shopify subscription create failed: no confirmation url returned");
4945
5319
  }
5320
+ ;
5321
+ return {
5322
+ confirmationUrl: payload.confirmationUrl,
5323
+ subscription: payload.appSubscription
5324
+ };
4946
5325
  };
4947
- var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
4948
- "customers/data_request",
5326
+ var createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = "USD", description, domain, idempotencyKey, subscriptionLineItemId }) => {
5327
+ var _a;
5328
+ if (!subscriptionLineItemId || !description || !(Number(amount) > 0)) {
5329
+ throw new Error("createUsageRecord requires subscriptionLineItemId, description, and a positive amount");
5330
+ }
5331
+ ;
5332
+ const data = await adminFetch({
5333
+ fetcher,
5334
+ adminAccessToken,
5335
+ domain,
5336
+ query: `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
5337
+ appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
5338
+ appUsageRecord { id }
5339
+ userErrors { field message }
5340
+ }
5341
+ }`,
5342
+ variables: {
5343
+ description,
5344
+ idempotencyKey: idempotencyKey ? String(idempotencyKey) : null,
5345
+ price: { amount: Number(amount), currencyCode },
5346
+ subscriptionLineItemId
5347
+ }
5348
+ });
5349
+ const payload = data == null ? void 0 : data.appUsageRecordCreate;
5350
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5351
+ if (userErrors.length || !((_a = payload == null ? void 0 : payload.appUsageRecord) == null ? void 0 : _a.id)) {
5352
+ throw new Error("Shopify usage record failed: " + (userErrors.map((entry) => entry.message).join("; ") || "no record returned"));
5353
+ }
5354
+ ;
5355
+ return { id: payload.appUsageRecord.id };
5356
+ };
5357
+ var getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
5358
+ var _a, _b;
5359
+ const data = await adminFetch({
5360
+ fetcher,
5361
+ adminAccessToken,
5362
+ domain,
5363
+ query: `{
5364
+ product( id : "${productId}" ) {
5365
+ variants( first : 100 ) {
5366
+ edges {
5367
+ node {
5368
+ id
5369
+ inventoryQuantity
5370
+ }
5371
+ }
5372
+ }
5373
+ }
5374
+ }`
5375
+ });
5376
+ const edges = ((_b = (_a = data == null ? void 0 : data.product) == null ? void 0 : _a.variants) == null ? void 0 : _b.edges) || [];
5377
+ return edges.reduce((accumulator, edge) => {
5378
+ var _a2, _b2;
5379
+ const id = (_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id;
5380
+ const quantity = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.inventoryQuantity;
5381
+ if (id) {
5382
+ accumulator[id] = Number.isFinite(quantity) ? quantity : 0;
5383
+ }
5384
+ ;
5385
+ return accumulator;
5386
+ }, {});
5387
+ };
5388
+ var getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
5389
+ var _a;
5390
+ const data = await adminFetch({
5391
+ fetcher,
5392
+ adminAccessToken,
5393
+ domain,
5394
+ query: `
5395
+ query ProductUpdatedAt($id: ID!) {
5396
+ product(id: $id) {
5397
+ updatedAt
5398
+ }
5399
+ }
5400
+ `,
5401
+ variables: { id: productId }
5402
+ });
5403
+ return ((_a = data == null ? void 0 : data.product) == null ? void 0 : _a.updatedAt) || null;
5404
+ };
5405
+ var NOT_ON_CHANNEL = "not available to the channel";
5406
+ var OUTDATED_FEEDBACK = "later version";
5407
+ var sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
5408
+ var _a, _b, _c;
5409
+ const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
5410
+ if (!productUpdatedAt) return null;
5411
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5412
+ const submit = async (version) => {
5413
+ const data = await adminFetch({
5414
+ fetcher,
5415
+ adminAccessToken,
5416
+ domain,
5417
+ query: `
5418
+ mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
5419
+ bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
5420
+ feedback {
5421
+ productId
5422
+ state
5423
+ }
5424
+ userErrors {
5425
+ field
5426
+ message
5427
+ }
5428
+ }
5429
+ }
5430
+ `,
5431
+ variables: {
5432
+ feedbackInput: [
5433
+ {
5434
+ productId,
5435
+ state,
5436
+ feedbackGeneratedAt: now,
5437
+ productUpdatedAt: version,
5438
+ messages
5439
+ }
5440
+ ]
5441
+ }
5442
+ });
5443
+ return data == null ? void 0 : data.bulkProductResourceFeedbackCreate;
5444
+ };
5445
+ let result = await submit(productUpdatedAt);
5446
+ if ((((_b = (_a = result == null ? void 0 : result.userErrors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) || "").toLowerCase().includes(OUTDATED_FEEDBACK)) {
5447
+ result = await submit(now);
5448
+ }
5449
+ ;
5450
+ const feedbackErrors = result == null ? void 0 : result.userErrors;
5451
+ if (feedbackErrors == null ? void 0 : feedbackErrors.length) {
5452
+ const message = feedbackErrors[0].message || "";
5453
+ if (message.toLowerCase().includes(NOT_ON_CHANNEL)) return null;
5454
+ throw new Error("Shopify product feedback failed: " + message);
5455
+ }
5456
+ ;
5457
+ return ((_c = result == null ? void 0 : result.feedback) == null ? void 0 : _c[0]) || null;
5458
+ };
5459
+
5460
+ // lib/shopify/billing.js
5461
+ var billing_exports = {};
5462
+ __export(billing_exports, {
5463
+ sendAppEvent: () => sendAppEvent
5464
+ });
5465
+ var APP_API = "https://api.shopify.com";
5466
+ var APP_API_VERSION = process.env.SHOPIFY_APP_API_VERSION || SHOPIFY_APP_API_VERSION;
5467
+ var cachedTokens = {};
5468
+ var fetchAppToken = async ({ clientId, clientSecret, fetcher }) => {
5469
+ const data = await request({
5470
+ fetcher,
5471
+ method: "POST",
5472
+ url: APP_API + "/auth/access_token",
5473
+ body: {
5474
+ client_id: clientId,
5475
+ client_secret: clientSecret,
5476
+ grant_type: "client_credentials"
5477
+ }
5478
+ });
5479
+ if (!(data == null ? void 0 : data.access_token)) {
5480
+ throw new Error("Shopify app token request failed: no access token returned");
5481
+ }
5482
+ ;
5483
+ const ttl = data.expires_in ? data.expires_in * 1e3 : 60 * 60 * 1e3;
5484
+ cachedTokens[clientId] = {
5485
+ expiresAt: Date.now() + ttl - 60 * 1e3,
5486
+ token: data.access_token
5487
+ };
5488
+ return data.access_token;
5489
+ };
5490
+ var getAppToken = async ({ clientId, clientSecret, fetcher }) => {
5491
+ const cached = cachedTokens[clientId];
5492
+ if (cached && cached.expiresAt > Date.now()) {
5493
+ return cached.token;
5494
+ }
5495
+ ;
5496
+ return fetchAppToken({ clientId, clientSecret, fetcher });
5497
+ };
5498
+ var toShopGid = (shopId) => String(shopId).startsWith("gid://") ? String(shopId) : "gid://shopify/Shop/" + shopId;
5499
+ var sendAppEvent = async ({ fetcher, clientId, clientSecret, eventHandle, idempotencyKey, reference: reference2, revision, shopId, timestamp, value }) => {
5500
+ if (!shopId || !eventHandle || !(Number(value) > 0)) {
5501
+ throw new Error("sendAppEvent requires shopId, eventHandle, and a positive value");
5502
+ }
5503
+ ;
5504
+ if (!clientId || !clientSecret) {
5505
+ throw new Error("sendAppEvent requires clientId and clientSecret");
5506
+ }
5507
+ ;
5508
+ const suffix = revision ? ".r" + revision : "";
5509
+ const key = idempotencyKey && String(idempotencyKey).slice(0, Math.max(0, 64 - suffix.length)) + suffix;
5510
+ const body = {
5511
+ attributes: {
5512
+ value: Number(value),
5513
+ ...reference2 && {
5514
+ reference: String(reference2).slice(0, 128)
5515
+ }
5516
+ },
5517
+ event_handle: eventHandle,
5518
+ shop_id: toShopGid(shopId),
5519
+ timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString(),
5520
+ ...key && {
5521
+ idempotency_key: key
5522
+ }
5523
+ };
5524
+ const send2 = async (token) => request({
5525
+ method: "POST",
5526
+ url: APP_API + "/app/" + APP_API_VERSION + "/events",
5527
+ headers: {
5528
+ "Authorization": "Bearer " + token
5529
+ },
5530
+ body
5531
+ });
5532
+ try {
5533
+ return await send2(await getAppToken({ clientId, clientSecret, fetcher }));
5534
+ } catch (error) {
5535
+ if ((error == null ? void 0 : error.status) === 401) {
5536
+ delete cachedTokens[clientId];
5537
+ return send2(await fetchAppToken({ clientId, clientSecret, fetcher }));
5538
+ }
5539
+ ;
5540
+ throw error;
5541
+ }
5542
+ };
5543
+
5544
+ // lib/shopify/oauth.js
5545
+ var oauth_exports = {};
5546
+ __export(oauth_exports, {
5547
+ SHOPIFY_REQUIRED_SCOPES: () => SHOPIFY_REQUIRED_SCOPES,
5548
+ createStorefrontToken: () => createStorefrontToken,
5549
+ getAdminToken: () => getAdminToken,
5550
+ getShop: () => getShop,
5551
+ missingScopes: () => missingScopes2,
5552
+ ping: () => ping,
5553
+ refreshAdminToken: () => refreshAdminToken,
5554
+ resolveConnectionSettings: () => resolveConnectionSettings
5555
+ });
5556
+ var required = (name, value) => {
5557
+ if (!value) {
5558
+ throw new Error("Shopify " + name + " is required");
5559
+ }
5560
+ ;
5561
+ return value;
5562
+ };
5563
+ var missingScopes2 = (granted) => {
5564
+ const grantedSet = new Set(
5565
+ (Array.isArray(granted) ? granted : String(granted || "").split(",")).map((scope) => scope.trim()).filter(Boolean)
5566
+ );
5567
+ const satisfied = (scope) => grantedSet.has(scope) || grantedSet.has(scope.replace(/(^|_)read_/, "$1write_"));
5568
+ return SHOPIFY_REQUIRED_SCOPES.filter((scope) => !satisfied(scope));
5569
+ };
5570
+ var ping = async ({ fetcher, adminAccessToken, domain }) => {
5571
+ var _a;
5572
+ const response = await fetch(
5573
+ `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`,
5574
+ {
5575
+ body: JSON.stringify({ query: "{ shop { name } }" }),
5576
+ headers: {
5577
+ "Content-Type": "application/json",
5578
+ "X-Shopify-Access-Token": adminAccessToken
5579
+ },
5580
+ method: "POST"
5581
+ }
5582
+ );
5583
+ if (!response.ok) {
5584
+ const text = await response.text().catch(() => "");
5585
+ const error = new Error(response.status + ": " + text);
5586
+ error.status = response.status;
5587
+ throw error;
5588
+ }
5589
+ ;
5590
+ const { errors } = await response.json().catch(() => ({}));
5591
+ if (errors == null ? void 0 : errors.length) {
5592
+ throw new Error("GraphQL: " + (((_a = errors[0]) == null ? void 0 : _a.message) || "unknown error"));
5593
+ }
5594
+ ;
5595
+ };
5596
+ var OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
5597
+ var parseOAuthErrorCode = (text) => {
5598
+ var _a;
5599
+ try {
5600
+ const parsed = JSON.parse(text);
5601
+ if (parsed == null ? void 0 : parsed.error) return parsed.error;
5602
+ } catch {
5603
+ }
5604
+ ;
5605
+ return ((_a = text.match(OAUTH_ERROR_TITLE_PATTERN)) == null ? void 0 : _a[1]) || null;
5606
+ };
5607
+ var shopifyOAuthFetch = async (url, body) => {
5608
+ const response = await fetch(url, {
5609
+ method: "POST",
5610
+ headers: {
5611
+ "Content-Type": "application/json"
5612
+ },
5613
+ body: JSON.stringify(body)
5614
+ });
5615
+ if (!response.ok) {
5616
+ const text = await response.text().catch(() => "");
5617
+ const error = new Error(response.status + ": " + text);
5618
+ error.status = response.status;
5619
+ error.code = parseOAuthErrorCode(text);
5620
+ throw error;
5621
+ }
5622
+ ;
5623
+ return response.json();
5624
+ };
5625
+ var getShop = async ({ fetcher, adminAccessToken, shop }) => {
5626
+ const data = await adminFetch({
5627
+ fetcher,
5628
+ adminAccessToken,
5629
+ domain: shop,
5630
+ query: `{
5631
+ shop {
5632
+ currencyCode
5633
+ id
5634
+ myshopifyDomain
5635
+ name
5636
+ }
5637
+ }`
5638
+ });
5639
+ const shopData = data == null ? void 0 : data.shop;
5640
+ if (!(shopData == null ? void 0 : shopData.id)) {
5641
+ throw new Error("Shopify shop lookup failed: no shop returned");
5642
+ }
5643
+ ;
5644
+ return {
5645
+ currency: shopData.currencyCode || null,
5646
+ id: shopData.id.split("/").pop(),
5647
+ myshopifyDomain: shopData.myshopifyDomain,
5648
+ name: shopData.name
5649
+ };
5650
+ };
5651
+ var createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
5652
+ var _a, _b, _c, _d, _e;
5653
+ try {
5654
+ const listed = await adminFetch({
5655
+ fetcher,
5656
+ adminAccessToken,
5657
+ domain: shop,
5658
+ query: `{
5659
+ shop {
5660
+ storefrontAccessTokens(first: 100) {
5661
+ edges {
5662
+ node {
5663
+ id
5664
+ title
5665
+ }
5666
+ }
5667
+ }
5668
+ }
5669
+ }`
5670
+ });
5671
+ 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);
5672
+ const orphaned = existing.filter((token) => (token == null ? void 0 : token.title) === "Drawbridge");
5673
+ await Promise.allSettled(
5674
+ orphaned.map((token) => adminFetch({
5675
+ adminAccessToken,
5676
+ domain: shop,
5677
+ query: `
5678
+ mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
5679
+ storefrontAccessTokenDelete(input: $input) {
5680
+ deletedStorefrontAccessTokenId
5681
+ userErrors {
5682
+ field
5683
+ message
5684
+ }
5685
+ }
5686
+ }
5687
+ `,
5688
+ variables: {
5689
+ input: {
5690
+ id: token.id
5691
+ }
5692
+ }
5693
+ }))
5694
+ );
5695
+ } catch {
5696
+ }
5697
+ const created = await adminFetch({
5698
+ fetcher,
5699
+ adminAccessToken,
5700
+ domain: shop,
5701
+ query: `
5702
+ mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
5703
+ storefrontAccessTokenCreate(input: $input) {
5704
+ storefrontAccessToken {
5705
+ accessToken
5706
+ }
5707
+ userErrors {
5708
+ field
5709
+ message
5710
+ }
5711
+ }
5712
+ }
5713
+ `,
5714
+ variables: {
5715
+ input: {
5716
+ title: "Drawbridge"
5717
+ }
5718
+ }
5719
+ });
5720
+ const createErrors = (_c = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _c.userErrors;
5721
+ if (createErrors == null ? void 0 : createErrors.length) {
5722
+ throw new Error("Shopify storefront token create failed: " + createErrors[0].message);
5723
+ }
5724
+ ;
5725
+ return ((_e = (_d = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _d.storefrontAccessToken) == null ? void 0 : _e.accessToken) || null;
5726
+ };
5727
+ var resolveTokenContext = async ({ connection, controller }) => {
5728
+ const decrypted = (connection == null ? void 0 : connection.settings) ? decrypt(connection.settings) : {};
5729
+ if ((decrypted == null ? void 0 : decrypted.ref) !== "shop") {
5730
+ return {
5731
+ raw: (connection == null ? void 0 : connection.settings) || null,
5732
+ session: null,
5733
+ settings: decrypted,
5734
+ shop: null,
5735
+ target: "connection"
5736
+ };
5737
+ }
5738
+ ;
5739
+ const shop = decrypted.shop;
5740
+ if (!controller || !shop) {
5741
+ return { raw: null, session: {}, settings: {}, shop, target: "shop" };
5742
+ }
5743
+ ;
5744
+ const record = await controller.get({ collection: "shop", query: { shop } });
5745
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : {};
5746
+ const storefront = (record == null ? void 0 : record.storefront) ? decrypt(record.storefront) : null;
5747
+ return {
5748
+ raw: (record == null ? void 0 : record.settings) || null,
5749
+ session,
5750
+ settings: {
5751
+ adminAccessToken: session.accessToken || null,
5752
+ refreshToken: session.refreshToken || null,
5753
+ refreshTokenExpiresAt: session.refreshTokenExpires || null,
5754
+ storefrontAccessToken: (storefront == null ? void 0 : storefront.token) || null,
5755
+ tokenExpiresAt: session.expires || null
5756
+ },
5757
+ shop,
5758
+ target: "shop"
5759
+ };
5760
+ };
5761
+ var resolveConnectionSettings = async ({ fetcher, connection, controller }) => {
5762
+ const { settings } = await resolveTokenContext({ connection, controller });
5763
+ return settings;
5764
+ };
5765
+ var refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
5766
+ var _a;
5767
+ const { raw, session, settings, shop, target } = await resolveTokenContext({ connection, controller });
5768
+ const domain = (connection == null ? void 0 : connection.shop) || ((_a = settings.source) == null ? void 0 : _a.domain) || settings.domain || shop;
5769
+ const { refreshToken } = settings;
5770
+ const data = await shopifyOAuthFetch(
5771
+ `https://${domain}/admin/oauth/access_token`,
5772
+ {
5773
+ grant_type: "refresh_token",
5774
+ // Throw before the grant: rotation RETIRES the old pair the moment it
5775
+ // returns, so a credential-less call that Shopify rejects opaquely is
5776
+ // the one failure mode worth being loud about.
5777
+ client_id: required("clientId", clientId),
5778
+ client_secret: required("clientSecret", clientSecret),
5779
+ refresh_token: refreshToken
5780
+ }
5781
+ );
5782
+ const adminAccessToken = data.access_token;
5783
+ const newRefreshToken = data.refresh_token;
5784
+ const expiresIn = data.expires_in;
5785
+ const tokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : null;
5786
+ const refreshTokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS);
5787
+ let liveToken = adminAccessToken;
5788
+ if (target === "shop") {
5789
+ const written = await controller.update({
5790
+ collection: "shop",
5791
+ data: {
5792
+ $set: {
5793
+ settings: encrypt({
5794
+ ...session,
5795
+ accessToken: adminAccessToken,
5796
+ expires: tokenExpiresAt,
5797
+ refreshToken: newRefreshToken,
5798
+ refreshTokenExpires: refreshTokenExpiresAt
5799
+ })
5800
+ }
5801
+ },
5802
+ query: {
5803
+ shop,
5804
+ ...raw && { settings: raw }
5805
+ }
5806
+ });
5807
+ if (!written) {
5808
+ const current = await controller.get({ collection: "shop", query: { shop } });
5809
+ if (!(current == null ? void 0 : current.settings)) {
5810
+ throw new Error("Shopify token refresh write-back failed: shop record missing for " + shop);
5811
+ }
5812
+ ;
5813
+ const currentSession = decrypt(current.settings);
5814
+ if ((currentSession == null ? void 0 : currentSession.accessToken) && currentSession.accessToken !== settings.adminAccessToken) {
5815
+ liveToken = currentSession.accessToken;
5816
+ } else {
5817
+ throw new Error("Shopify token refresh write-back failed for " + shop);
5818
+ }
5819
+ ;
5820
+ }
5821
+ ;
5822
+ } else {
5823
+ const written = await controller.update({
5824
+ collection: "connection",
5825
+ data: {
5826
+ $set: {
5827
+ settings: encrypt({
5828
+ ...settings,
5829
+ adminAccessToken,
5830
+ refreshToken: newRefreshToken,
5831
+ refreshTokenExpiresAt,
5832
+ tokenExpiresAt
5833
+ })
5834
+ }
5835
+ },
5836
+ query: {
5837
+ id: connection.id,
5838
+ ...raw && { settings: raw }
5839
+ }
5840
+ });
5841
+ if (!written) {
5842
+ const current = await controller.get({ collection: "connection", query: { id: connection.id } });
5843
+ const currentSettings = (current == null ? void 0 : current.settings) ? decrypt(current.settings) : null;
5844
+ if ((currentSettings == null ? void 0 : currentSettings.adminAccessToken) && currentSettings.adminAccessToken !== settings.adminAccessToken) {
5845
+ liveToken = currentSettings.adminAccessToken;
5846
+ } else {
5847
+ throw new Error("Shopify token refresh write-back failed for connection " + (connection == null ? void 0 : connection.id));
5848
+ }
5849
+ ;
5850
+ }
5851
+ ;
5852
+ }
5853
+ ;
5854
+ await ping({ adminAccessToken: liveToken, domain });
5855
+ return liveToken;
5856
+ };
5857
+ var getAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
5858
+ required("clientId", clientId);
5859
+ required("clientSecret", clientSecret);
5860
+ const settings = await resolveConnectionSettings({ connection, controller });
5861
+ const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
5862
+ if (!adminAccessToken) {
5863
+ return null;
5864
+ }
5865
+ ;
5866
+ if (!refreshToken) {
5867
+ return adminAccessToken;
5868
+ }
5869
+ ;
5870
+ const needsRefresh = !tokenExpiresAt || new Date(tokenExpiresAt) < new Date(Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS);
5871
+ if (needsRefresh) {
5872
+ return refreshAdminToken({ clientId, clientSecret, connection, controller });
5873
+ }
5874
+ ;
5875
+ return adminAccessToken;
5876
+ };
5877
+
5878
+ // lib/shopify/partner.js
5879
+ var partner_exports = {};
5880
+ __export(partner_exports, {
5881
+ getUsageChargeEvents: () => getUsageChargeEvents
5882
+ });
5883
+ var PARTNER_API = "https://partners.shopify.com";
5884
+ var getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
5885
+ var _a, _b, _c;
5886
+ if (!partnerToken || !organizationId || !appId) {
5887
+ throw new Error("getUsageChargeEvents requires partnerToken, organizationId, and appId");
5888
+ }
5889
+ ;
5890
+ const data = await request({
5891
+ fetcher,
5892
+ method: "POST",
5893
+ url: PARTNER_API + "/" + organizationId + "/api/" + PARTNER_API_VERSION + "/graphql.json",
5894
+ headers: {
5895
+ "X-Shopify-Access-Token": partnerToken
5896
+ },
5897
+ body: {
5898
+ query: `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
5899
+ events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
5900
+ edges {
5901
+ node {
5902
+ id
5903
+ occurredAt
5904
+ eventType
5905
+ shop { id myshopifyDomain }
5906
+ ... on Charge {
5907
+ chargeId
5908
+ chargeType
5909
+ amount { amount currencyCode }
5910
+ usageQuantity
5911
+ planHandle
5912
+ description
5913
+ }
5914
+ }
5915
+ }
5916
+ pageInfo { hasNextPage endCursor }
5917
+ }
5918
+ }`,
5919
+ variables: {
5920
+ filter: {
5921
+ eventTypes: ["CHARGE_USAGE"],
5922
+ subjectId: String(appId),
5923
+ ...occurredAtMin && { occurredAtMin },
5924
+ ...shopId && { shopId: String(shopId) }
5925
+ },
5926
+ first
5927
+ }
5928
+ }
5929
+ });
5930
+ if ((_a = data == null ? void 0 : data.errors) == null ? void 0 : _a.length) {
5931
+ throw new Error("Shopify partner events query failed: " + data.errors.map((entry) => entry.message).join("; "));
5932
+ }
5933
+ ;
5934
+ return (((_c = (_b = data == null ? void 0 : data.data) == null ? void 0 : _b.events) == null ? void 0 : _c.edges) || []).map((edge) => {
5935
+ var _a2, _b2, _c2, _d, _e, _f, _g, _h, _i, _j;
5936
+ return {
5937
+ amount: ((_a2 = edge.node) == null ? void 0 : _a2.amount) || null,
5938
+ chargeId: ((_b2 = edge.node) == null ? void 0 : _b2.chargeId) || null,
5939
+ // USAGE vs the recurring plan charge an approval emits — the
5940
+ // disambiguator callers filter on.
5941
+ chargeType: ((_c2 = edge.node) == null ? void 0 : _c2.chargeType) || null,
5942
+ description: ((_d = edge.node) == null ? void 0 : _d.description) || null,
5943
+ eventType: ((_e = edge.node) == null ? void 0 : _e.eventType) || null,
5944
+ id: ((_f = edge.node) == null ? void 0 : _f.id) || null,
5945
+ occurredAt: ((_g = edge.node) == null ? void 0 : _g.occurredAt) || null,
5946
+ planHandle: ((_h = edge.node) == null ? void 0 : _h.planHandle) || null,
5947
+ shop: ((_i = edge.node) == null ? void 0 : _i.shop) || null,
5948
+ usageQuantity: ((_j = edge.node) == null ? void 0 : _j.usageQuantity) ?? null
5949
+ };
5950
+ });
5951
+ };
5952
+
5953
+ // lib/shopify/storefront.js
5954
+ var storefront_exports = {};
5955
+ __export(storefront_exports, {
5956
+ cartCreate: () => cartCreate,
5957
+ cartGet: () => cartGet,
5958
+ cartLinesAdd: () => cartLinesAdd,
5959
+ cartLinesRemove: () => cartLinesRemove,
5960
+ cartLinesUpdate: () => cartLinesUpdate,
5961
+ createCheckoutToken: () => createCheckoutToken,
5962
+ getProduct: () => getProduct,
5963
+ getProductVariantsPage: () => getProductVariantsPage,
5964
+ getProducts: () => getProducts,
5965
+ probeVariantsOutOfStock: () => probeVariantsOutOfStock
5966
+ });
5967
+ var SORT_KEY_MAP = {
5968
+ bestSelling: "BEST_SELLING",
5969
+ createdAt: "CREATED_AT",
5970
+ id: "ID",
5971
+ price: "PRICE",
5972
+ productType: "PRODUCT_TYPE",
5973
+ title: "TITLE",
5974
+ updatedAt: "UPDATED_AT",
5975
+ vendor: "VENDOR"
5976
+ };
5977
+ var resolveProductSort = (sort) => {
5978
+ const entry = sort && Object.entries(sort)[0];
5979
+ if (!entry) return null;
5980
+ const [field, direction] = entry;
5981
+ const sortKey = SORT_KEY_MAP[field];
5982
+ if (!sortKey) return null;
5983
+ return {
5984
+ sortKey,
5985
+ reverse: Number(direction) < 0
5986
+ };
5987
+ };
5988
+ var storefrontUrl = (domain) => `https://${domain}/api/${SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`;
5989
+ var storefrontFetch = async ({ fetcher, domain, storefrontAccessToken, query, variables }) => {
5990
+ return request({
5991
+ method: "POST",
5992
+ url: storefrontUrl(domain),
5993
+ headers: {
5994
+ "X-Shopify-Storefront-Access-Token": storefrontAccessToken
5995
+ },
5996
+ body: {
5997
+ query,
5998
+ ...variables && { variables }
5999
+ }
6000
+ });
6001
+ };
6002
+ var shopCountryCache = {};
6003
+ var getShopCountry = async ({ domain, fetcher, storefrontAccessToken }) => {
6004
+ var _a, _b;
6005
+ if (shopCountryCache[domain]) return shopCountryCache[domain];
6006
+ const { data } = await storefrontFetch({
6007
+ fetcher,
6008
+ domain,
6009
+ storefrontAccessToken,
6010
+ query: `{
6011
+ shop {
6012
+ paymentSettings {
6013
+ countryCode
6014
+ }
6015
+ }
6016
+ }`
6017
+ });
6018
+ const countryCode = ((_b = (_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.paymentSettings) == null ? void 0 : _b.countryCode) || null;
6019
+ if (countryCode) shopCountryCache[domain] = countryCode;
6020
+ return countryCode;
6021
+ };
6022
+ var OUT_OF_STOCK_WARNING_CODES = [
6023
+ "MERCHANDISE_OUT_OF_STOCK",
6024
+ "MERCHANDISE_NOT_ENOUGH_STOCK"
6025
+ ];
6026
+ var probeVariantsOutOfStock = async ({ fetcher, domain, storefrontAccessToken, variantIds, countryCode }) => {
6027
+ var _a, _b;
6028
+ if (!(variantIds == null ? void 0 : variantIds.length)) return [];
6029
+ const resolvedCountry = countryCode || await getShopCountry({ domain, fetcher, storefrontAccessToken });
6030
+ const { data, errors } = await storefrontFetch({
6031
+ fetcher,
6032
+ domain,
6033
+ storefrontAccessToken,
6034
+ query: `
6035
+ mutation probe( $input : CartInput! ){
6036
+ cartCreate( input : $input ){
6037
+ cart {
6038
+ lines( first : 250 ){
6039
+ edges {
6040
+ node {
6041
+ id
6042
+ merchandise {
6043
+ ... on ProductVariant {
6044
+ id
6045
+ }
6046
+ }
6047
+ }
6048
+ }
6049
+ }
6050
+ }
6051
+ warnings {
6052
+ code
6053
+ message
6054
+ target
6055
+ }
6056
+ }
6057
+ }
6058
+ `,
6059
+ variables: {
6060
+ input: {
6061
+ lines: variantIds.map((merchandiseId) => ({
6062
+ merchandiseId,
6063
+ quantity: 1
6064
+ })),
6065
+ ...resolvedCountry && {
6066
+ buyerIdentity: {
6067
+ countryCode: resolvedCountry
6068
+ }
6069
+ }
6070
+ }
6071
+ }
6072
+ });
6073
+ if (errors) return [];
6074
+ const payload = data == null ? void 0 : data.cartCreate;
6075
+ const soldOut = new Set(
6076
+ ((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)
6077
+ );
6078
+ 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 }) => {
6079
+ var _a2;
6080
+ return (_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.id;
6081
+ }).filter(Boolean);
6082
+ };
6083
+ var productsQuery = (search, cursor, limit, sort) => {
6084
+ const resolved = resolveProductSort(sort);
6085
+ const sortArgs = resolved ? `, sortKey: ${resolved.sortKey}, reverse: ${resolved.reverse}` : "";
6086
+ return `{
6087
+ products(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}${sortArgs}) {
6088
+ edges {
6089
+ node {
6090
+ id
6091
+ title
6092
+ description
6093
+ handle
6094
+ productType
6095
+ featuredImage {
6096
+ url
6097
+ }
6098
+ variants(first: 10) {
6099
+ edges {
6100
+ node {
6101
+ id
6102
+ title
6103
+ image {
6104
+ url
6105
+ }
6106
+ price {
6107
+ amount
6108
+ currencyCode
6109
+ }
6110
+ compareAtPrice {
6111
+ amount
6112
+ currencyCode
6113
+ }
6114
+ availableForSale
6115
+ requiresShipping
6116
+ }
6117
+ }
6118
+ }
6119
+ }
6120
+ }
6121
+ pageInfo {
6122
+ endCursor
6123
+ hasNextPage
6124
+ hasPreviousPage
6125
+ startCursor
6126
+ }
6127
+ }
6128
+ }`;
6129
+ };
6130
+ var productQuery = (productId) => `{
6131
+ product(id: "${productId}") {
6132
+ id
6133
+ title
6134
+ description
6135
+ handle
6136
+ productType
6137
+ featuredImage {
6138
+ url
6139
+ }
6140
+ variants(first: 10) {
6141
+ edges {
6142
+ node {
6143
+ id
6144
+ title
6145
+ image {
6146
+ url
6147
+ }
6148
+ price {
6149
+ amount
6150
+ currencyCode
6151
+ }
6152
+ compareAtPrice {
6153
+ amount
6154
+ currencyCode
6155
+ }
6156
+ availableForSale
6157
+ requiresShipping
6158
+ }
6159
+ }
6160
+ }
6161
+ }
6162
+ }`;
6163
+ var variantsQuery = (productId, cursor) => `{
6164
+ product(id: "${productId}") {
6165
+ variants(first: 250${cursor ? `, after: "${cursor}"` : ""}) {
6166
+ edges {
6167
+ node {
6168
+ id
6169
+ title
6170
+ image {
6171
+ url
6172
+ }
6173
+ price {
6174
+ amount
6175
+ currencyCode
6176
+ }
6177
+ compareAtPrice {
6178
+ amount
6179
+ currencyCode
6180
+ }
6181
+ availableForSale
6182
+ requiresShipping
6183
+ }
6184
+ }
6185
+ pageInfo {
6186
+ hasNextPage
6187
+ endCursor
6188
+ }
6189
+ }
6190
+ }
6191
+ }`;
6192
+ var getProducts = async ({ fetcher, domain, storefrontAccessToken, search, cursor, limit, sort }) => {
6193
+ const { data, errors } = await storefrontFetch({
6194
+ fetcher,
6195
+ domain,
6196
+ storefrontAccessToken,
6197
+ query: productsQuery(search, cursor, limit, sort)
6198
+ });
6199
+ if (errors) {
6200
+ throw new Error("Failed to fetch Shopify products");
6201
+ }
6202
+ ;
6203
+ return data == null ? void 0 : data.products;
6204
+ };
6205
+ var getProduct = async ({ fetcher, domain, storefrontAccessToken, productId }) => {
6206
+ var _a;
6207
+ const { data, errors } = await storefrontFetch({
6208
+ fetcher,
6209
+ domain,
6210
+ storefrontAccessToken,
6211
+ query: productQuery(productId)
6212
+ });
6213
+ if (errors) {
6214
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6215
+ }
6216
+ ;
6217
+ if (!(data == null ? void 0 : data.product)) {
6218
+ throw new Error("Shopify product not found");
6219
+ }
6220
+ ;
6221
+ return data.product;
6222
+ };
6223
+ var getProductVariantsPage = async ({ fetcher, domain, storefrontAccessToken, productId, cursor }) => {
6224
+ var _a;
6225
+ const { data, errors } = await storefrontFetch({
6226
+ fetcher,
6227
+ domain,
6228
+ storefrontAccessToken,
6229
+ query: variantsQuery(productId, cursor)
6230
+ });
6231
+ if (errors) {
6232
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6233
+ }
6234
+ ;
6235
+ if (!(data == null ? void 0 : data.product)) {
6236
+ throw new Error("Shopify product not found");
6237
+ }
6238
+ ;
6239
+ return data.product.variants;
6240
+ };
6241
+ var CART_FIELDS = `
6242
+ id
6243
+ checkoutUrl
6244
+ lines(first: 100) {
6245
+ edges {
6246
+ node {
6247
+ id
6248
+ quantity
6249
+ merchandise {
6250
+ ... on ProductVariant {
6251
+ id
6252
+ title
6253
+ availableForSale
6254
+ image {
6255
+ url
6256
+ }
6257
+ price {
6258
+ amount
6259
+ currencyCode
6260
+ }
6261
+ product {
6262
+ title
6263
+ }
6264
+ }
6265
+ }
6266
+ }
6267
+ }
6268
+ }
6269
+ `;
6270
+ var mapCart = (cart) => {
6271
+ var _a;
6272
+ if (!cart) return null;
6273
+ return {
6274
+ checkoutUrl: cart.checkoutUrl,
6275
+ id: cart.id,
6276
+ lines: (((_a = cart.lines) == null ? void 0 : _a.edges) || []).map(({ node }) => {
6277
+ var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
6278
+ return {
6279
+ availableForSale: ((_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.availableForSale) ?? true,
6280
+ currency: ((_c = (_b = node == null ? void 0 : node.merchandise) == null ? void 0 : _b.price) == null ? void 0 : _c.currencyCode) || null,
6281
+ id: node.id,
6282
+ image: ((_e = (_d = node == null ? void 0 : node.merchandise) == null ? void 0 : _d.image) == null ? void 0 : _e.url) || null,
6283
+ price: ((_g = (_f = node == null ? void 0 : node.merchandise) == null ? void 0 : _f.price) == null ? void 0 : _g.amount) || null,
6284
+ productTitle: ((_i = (_h = node == null ? void 0 : node.merchandise) == null ? void 0 : _h.product) == null ? void 0 : _i.title) || null,
6285
+ quantity: node.quantity,
6286
+ variantId: (_j = node == null ? void 0 : node.merchandise) == null ? void 0 : _j.id,
6287
+ variantTitle: ((_k = node == null ? void 0 : node.merchandise) == null ? void 0 : _k.title) || null
6288
+ };
6289
+ })
6290
+ };
6291
+ };
6292
+ var firstUserError = (userErrors) => {
6293
+ const error = userErrors == null ? void 0 : userErrors[0];
6294
+ if (!error) return null;
6295
+ const message = new Error(error.message || "Shopify cart error");
6296
+ message.userError = true;
6297
+ return message;
6298
+ };
6299
+ var createCheckoutToken = async ({ fetcher, clientId, clientSecret } = {}) => {
6300
+ if (!clientId || !clientSecret) {
6301
+ throw new Error("createCheckoutToken requires clientId and clientSecret");
6302
+ }
6303
+ ;
6304
+ const response = await request({
6305
+ fetcher,
6306
+ method: "POST",
6307
+ url: "https://api.shopify.com/auth/access_token",
6308
+ body: {
6309
+ client_id: clientId,
6310
+ client_secret: clientSecret,
6311
+ grant_type: "client_credentials"
6312
+ }
6313
+ });
6314
+ const token = response == null ? void 0 : response.access_token;
6315
+ if (!token) {
6316
+ throw new Error("Failed to create Shopify checkout token");
6317
+ }
6318
+ ;
6319
+ return token;
6320
+ };
6321
+ var cartCreate = async ({ fetcher, domain, storefrontAccessToken, lines }) => {
6322
+ var _a, _b;
6323
+ const { data, errors } = await storefrontFetch({
6324
+ fetcher,
6325
+ domain,
6326
+ storefrontAccessToken,
6327
+ query: `
6328
+ mutation cartCreate($input: CartInput!) {
6329
+ cartCreate(input: $input) {
6330
+ cart {
6331
+ ${CART_FIELDS}
6332
+ }
6333
+ userErrors {
6334
+ field
6335
+ message
6336
+ }
6337
+ }
6338
+ }
6339
+ `,
6340
+ variables: {
6341
+ input: {
6342
+ lines
6343
+ }
6344
+ }
6345
+ });
6346
+ if (errors) {
6347
+ throw new Error("Failed to create Shopify cart");
6348
+ }
6349
+ ;
6350
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartCreate) == null ? void 0 : _a.userErrors);
6351
+ if (userError) throw userError;
6352
+ return mapCart((_b = data == null ? void 0 : data.cartCreate) == null ? void 0 : _b.cart);
6353
+ };
6354
+ var cartGet = async ({ fetcher, domain, storefrontAccessToken, cartId }) => {
6355
+ const { data, errors } = await storefrontFetch({
6356
+ fetcher,
6357
+ domain,
6358
+ storefrontAccessToken,
6359
+ query: `
6360
+ query cart($id: ID!) {
6361
+ cart(id: $id) {
6362
+ ${CART_FIELDS}
6363
+ }
6364
+ }
6365
+ `,
6366
+ variables: {
6367
+ id: cartId
6368
+ }
6369
+ });
6370
+ if (errors) {
6371
+ throw new Error("Failed to fetch Shopify cart");
6372
+ }
6373
+ ;
6374
+ return mapCart(data == null ? void 0 : data.cart);
6375
+ };
6376
+ var cartLinesAdd = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6377
+ var _a, _b;
6378
+ const { data, errors } = await storefrontFetch({
6379
+ fetcher,
6380
+ domain,
6381
+ storefrontAccessToken,
6382
+ query: `
6383
+ mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
6384
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
6385
+ cart {
6386
+ ${CART_FIELDS}
6387
+ }
6388
+ userErrors {
6389
+ field
6390
+ message
6391
+ }
6392
+ }
6393
+ }
6394
+ `,
6395
+ variables: {
6396
+ cartId,
6397
+ lines
6398
+ }
6399
+ });
6400
+ if (errors) {
6401
+ throw new Error("Failed to add Shopify cart lines");
6402
+ }
6403
+ ;
6404
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _a.userErrors);
6405
+ if (userError) throw userError;
6406
+ return mapCart((_b = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _b.cart);
6407
+ };
6408
+ var cartLinesRemove = async ({ fetcher, domain, storefrontAccessToken, cartId, lineIds }) => {
6409
+ var _a, _b;
6410
+ const { data, errors } = await storefrontFetch({
6411
+ fetcher,
6412
+ domain,
6413
+ storefrontAccessToken,
6414
+ query: `
6415
+ mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
6416
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
6417
+ cart {
6418
+ ${CART_FIELDS}
6419
+ }
6420
+ userErrors {
6421
+ field
6422
+ message
6423
+ }
6424
+ }
6425
+ }
6426
+ `,
6427
+ variables: {
6428
+ cartId,
6429
+ lineIds
6430
+ }
6431
+ });
6432
+ if (errors) {
6433
+ throw new Error("Failed to remove Shopify cart lines");
6434
+ }
6435
+ ;
6436
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _a.userErrors);
6437
+ if (userError) throw userError;
6438
+ return mapCart((_b = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _b.cart);
6439
+ };
6440
+ var cartLinesUpdate = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6441
+ var _a, _b;
6442
+ const { data, errors } = await storefrontFetch({
6443
+ fetcher,
6444
+ domain,
6445
+ storefrontAccessToken,
6446
+ query: `
6447
+ mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
6448
+ cartLinesUpdate(cartId: $cartId, lines: $lines) {
6449
+ cart {
6450
+ ${CART_FIELDS}
6451
+ }
6452
+ userErrors {
6453
+ field
6454
+ message
6455
+ }
6456
+ }
6457
+ }
6458
+ `,
6459
+ variables: {
6460
+ cartId,
6461
+ lines
6462
+ }
6463
+ });
6464
+ if (errors) {
6465
+ throw new Error("Failed to update Shopify cart lines");
6466
+ }
6467
+ ;
6468
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _a.userErrors);
6469
+ if (userError) throw userError;
6470
+ return mapCart((_b = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _b.cart);
6471
+ };
6472
+
6473
+ // lib/email.js
6474
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
6475
+ var toCanonicalEmail = (value) => {
6476
+ if (!value || typeof value !== "string") return null;
6477
+ const email = value.trim().toLowerCase();
6478
+ const at = email.lastIndexOf("@");
6479
+ if (at < 1 || at === email.length - 1) return null;
6480
+ let local = email.slice(0, at);
6481
+ const domain = email.slice(at + 1);
6482
+ const plus = local.indexOf("+");
6483
+ if (plus > 0) local = local.slice(0, plus);
6484
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
6485
+ if (!local) return null;
6486
+ return local + "@" + domain;
6487
+ };
6488
+
6489
+ // lib/oauth/index.js
6490
+ var lifetimes = {
6491
+ access: 60 * 60 * 8,
6492
+ // 8 hours
6493
+ authorizationCode: 10 * 60,
6494
+ // 10 minutes
6495
+ code: 15 * 60,
6496
+ // 15 minutes (email OTC)
6497
+ refresh: 30 * 24 * 60 * 60
6498
+ // 30 days
6499
+ };
6500
+ var tokenTypes = {
6501
+ access: "access",
6502
+ oauthState: "oauth.state",
6503
+ pat: "pat",
6504
+ refresh: "refresh"
6505
+ };
6506
+ var scopes = [
6507
+ "profile:read",
6508
+ "profile:write",
6509
+ "organization:read",
6510
+ "organization:write",
6511
+ "campaigns:read",
6512
+ "campaigns:write",
6513
+ "contacts:read",
6514
+ "contacts:write",
6515
+ "workflows:read",
6516
+ "workflows:write",
6517
+ "connections:read",
6518
+ "connections:write",
6519
+ "billing:read",
6520
+ "billing:write",
6521
+ "admin"
6522
+ ];
6523
+ var developer = [
6524
+ "profile:read",
6525
+ "organization:read",
6526
+ "campaigns:read",
6527
+ "campaigns:write",
6528
+ "contacts:read",
6529
+ "contacts:write",
6530
+ "workflows:read",
6531
+ "workflows:write",
6532
+ "connections:read",
6533
+ "connections:write"
6534
+ ];
6535
+ var scopeLabels = {
6536
+ "profile:read": "Read own profile",
6537
+ "profile:write": "Update own profile",
6538
+ "organization:read": "Read organization info",
6539
+ "organization:write": "Manage organization",
6540
+ "campaigns:read": "Read campaigns",
6541
+ "campaigns:write": "Manage campaigns",
6542
+ "contacts:read": "Read contacts",
6543
+ "contacts:write": "Manage contacts",
6544
+ "workflows:read": "Read workflows",
6545
+ "workflows:write": "Manage workflows",
6546
+ "connections:read": "Read integrations",
6547
+ "connections:write": "Manage integrations",
6548
+ "billing:read": "Read billing",
6549
+ "billing:write": "Manage billing",
6550
+ admin: "Admin access"
6551
+ };
6552
+ var toOption = (scope) => ({
6553
+ key: scopeLabels[scope] || scope,
6554
+ value: scope
6555
+ });
6556
+ var developerOptions = developer.map(toOption);
6557
+ var scopeOptions = scopes.map(toOption);
6558
+ var hashToken = (raw) => hash(raw, process.env.HMAC_OAUTH_TOKEN_KEY);
6559
+
6560
+ // lib/sanitize.js
6561
+ import disposableDomains from "disposable-email-domains/index.js";
6562
+ var disposableBlacklist = new Set(disposableDomains.map((d) => d.toLowerCase()));
6563
+ var sanitizeDomain = (value, pattern) => {
6564
+ if (!value) return value;
6565
+ const cleaned = value.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
6566
+ if (pattern) {
6567
+ const match = cleaned.match(pattern);
6568
+ return match ? match[0] : cleaned;
6569
+ }
6570
+ return cleaned;
6571
+ };
6572
+
6573
+ // lib/slugify.js
6574
+ import slug from "slugify";
6575
+ var slugify = (value, nochars = false) => {
6576
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
6577
+ const replacement = nochars ? "" : "-";
6578
+ return slug(
6579
+ value,
6580
+ {
6581
+ remove,
6582
+ replacement,
6583
+ lower: true,
6584
+ trim: true
6585
+ }
6586
+ );
6587
+ };
6588
+
6589
+ // lib/connections/manifests/shopify.js
6590
+ var client = Object.freeze({
6591
+ admin: admin_exports,
6592
+ billing: billing_exports,
6593
+ oauth: oauth_exports,
6594
+ partner: partner_exports,
6595
+ storefront: storefront_exports
6596
+ });
6597
+ var toLine = ({
6598
+ price,
6599
+ product_id: productId,
6600
+ quantity,
6601
+ title,
6602
+ variant_id: variantId,
6603
+ variant_title: variantTitle
6604
+ }) => ({
6605
+ price: parseFloat(price) || 0,
6606
+ productId: productId ? "gid://shopify/Product/" + productId : null,
6607
+ quantity: quantity || 1,
6608
+ title: title || null,
6609
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
6610
+ variantTitle: variantTitle || null
6611
+ });
6612
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
6613
+ (acc, item) => {
6614
+ const attrs = (item.properties || []).reduce(
6615
+ (map, { name, value }) => {
6616
+ map[name] = value;
6617
+ return map;
6618
+ },
6619
+ {}
6620
+ );
6621
+ if (!attrs["_drwbrdg_ca"]) return acc;
6622
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
6623
+ const line = toLine(item);
6624
+ acc.attributedGross += line.price * line.quantity;
6625
+ acc.attributedLines.push(line);
6626
+ return acc;
6627
+ },
6628
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
6629
+ );
6630
+ var generateDiscountCode = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
6631
+ var blockedReason = (discount) => {
6632
+ var _a;
6633
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
6634
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
6635
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
6636
+ 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.";
6637
+ }
6638
+ ;
6639
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
6640
+ return "This discount has reached its total usage limit.";
6641
+ }
6642
+ ;
6643
+ return null;
6644
+ };
6645
+ var discountWarning = (discount) => {
6646
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
6647
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
6648
+ }
6649
+ ;
6650
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
6651
+ return null;
6652
+ };
6653
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
6654
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
6655
+ var OAUTH_ERROR_SOURCE = "oauth";
6656
+ var BILLING_ERROR_SOURCE = "billing";
6657
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
6658
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
6659
+ var inbound = {
6660
+ headers: {
6661
+ event: "x-shopify-topic",
6662
+ id: "x-shopify-webhook-id",
6663
+ shop: "x-shopify-shop-domain",
6664
+ signature: "x-shopify-hmac-sha256"
6665
+ },
6666
+ signature: {
6667
+ algorithm: "sha256",
6668
+ encoding: "base64",
6669
+ secret: "SHOPIFY_API_SECRET"
6670
+ }
6671
+ };
6672
+ var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
6673
+ "customers/data_request",
4949
6674
  "customers/redact",
4950
6675
  "shop/redact"
4951
6676
  ]);
6677
+ var refusal = (message, status) => Object.assign(new Error(message), { status });
6678
+ var LINK_TOKEN_TTL_MS = 30 * 60 * 1e3;
6679
+ var CONNECTABLE_STATUSES = ["active", "unsubscribed"];
6680
+ var merchantOrganizations = ({ read, user }) => read.aggregate({
6681
+ collection: "organization",
6682
+ pipeline: [
6683
+ // A member qualifies only at `manage`; an owner always qualifies.
6684
+ {
6685
+ $lookup: {
6686
+ as: "membership",
6687
+ from: "member",
6688
+ let: { organization: "$id" },
6689
+ pipeline: [
6690
+ {
6691
+ $match: {
6692
+ $expr: { $eq: ["$organization", "$$organization"] },
6693
+ "capabilities.connections": "manage",
6694
+ status: "accepted",
6695
+ user
6696
+ }
6697
+ },
6698
+ { $limit: 1 },
6699
+ { $project: { _id: 0, organization: 1 } }
6700
+ ]
6701
+ }
6702
+ },
6703
+ {
6704
+ $match: {
6705
+ status: { $ne: "canceled" },
6706
+ $or: [{ owner: user }, { "membership.0": { $exists: true } }]
6707
+ }
6708
+ },
6709
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
6710
+ // the list can read the real plan and conversion rate. Without it every org
6711
+ // rendered as free tier.
6712
+ { $lookup: { as: "subscription", foreignField: "id", from: "subscription", localField: "subscription" } },
6713
+ { $unwind: { path: "$subscription", preserveNullAndEmptyArrays: true } },
6714
+ {
6715
+ $lookup: {
6716
+ as: "connection",
6717
+ foreignField: "organization",
6718
+ from: "connection",
6719
+ localField: "id",
6720
+ pipeline: [
6721
+ { $match: { slug: "shopify" } },
6722
+ { $limit: 1 },
6723
+ { $project: { _id: 0, id: 1, shop: 1, status: 1 } }
6724
+ ]
6725
+ }
6726
+ },
6727
+ { $unwind: { path: "$connection", preserveNullAndEmptyArrays: true } },
6728
+ { $project: { _id: 0, billingStatus: "$status", connection: 1, id: 1, name: "$title", subscription: 1 } },
6729
+ { $sort: { name: 1 } }
6730
+ ]
6731
+ });
6732
+ var storePlan = async ({ record, shopify }) => {
6733
+ var _a;
6734
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
6735
+ if (!(settings == null ? void 0 : settings.accessToken)) return { active: false, name: null };
6736
+ try {
6737
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6738
+ adminAccessToken: settings.accessToken,
6739
+ domain: record.shop
6740
+ });
6741
+ const active = (subscriptions || []).length > 0;
6742
+ const metered = active && subscriptions.some((subscription) => subscription.metered === true) ? true : null;
6743
+ return { active, metered, name: active ? ((_a = subscriptions[0]) == null ? void 0 : _a.name) || null : null };
6744
+ } catch (_) {
6745
+ return { active: false, name: null, unavailable: true };
6746
+ }
6747
+ };
6748
+ var releaseApprovals = async ({ context, read, shopify }) => {
6749
+ var _a;
6750
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
6751
+ if (!shop) throw refusal("shop is required", 400);
6752
+ const record = await read.get({ collection: "shop", query: { shop } });
6753
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
6754
+ if (!(settings == null ? void 0 : settings.accessToken)) throw refusal("Store is not installed", 409);
6755
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6756
+ adminAccessToken: settings.accessToken,
6757
+ domain: shop
6758
+ });
6759
+ for (const subscription of subscriptions || []) {
6760
+ const data = await shopify.admin.adminFetch({
6761
+ adminAccessToken: settings.accessToken,
6762
+ domain: shop,
6763
+ query: "mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }",
6764
+ variables: { id: subscription.id }
6765
+ });
6766
+ const [userError] = ((_a = data == null ? void 0 : data.appSubscriptionCancel) == null ? void 0 : _a.userErrors) || [];
6767
+ if (userError) throw refusal(userError.message, 422);
6768
+ }
6769
+ return {
6770
+ message: "Released " + (subscriptions || []).length + " approval(s).",
6771
+ request: { shop },
6772
+ result: { ok: true }
6773
+ };
6774
+ };
6775
+ var LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
6776
+ var meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
6777
+ var _a;
6778
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain: shop });
6779
+ let metered = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
6780
+ if (!((partner == null ? void 0 : partner.partnerToken) && (partner == null ? void 0 : partner.partnerOrgId) && (partner == null ? void 0 : partner.partnerAppId) && shopId)) {
6781
+ return { boundaries: [], ledger: false, metered };
6782
+ }
6783
+ const accruals = await shopify.partner.getUsageChargeEvents({
6784
+ appId: "gid://shopify/App/" + partner.partnerAppId,
6785
+ first: 10,
6786
+ occurredAtMin: ledgerFloor.toISOString(),
6787
+ organizationId: partner.partnerOrgId,
6788
+ partnerToken: partner.partnerToken,
6789
+ shopId: "gid://shopify/Shop/" + shopId
6790
+ });
6791
+ const boundaries = (accruals || []).filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
6792
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
6793
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
6794
+ return { boundaries, ledger: true, metered };
6795
+ };
6796
+ var toVariant = (edge) => {
6797
+ var _a, _b, _c, _d;
6798
+ return {
6799
+ availableForSale: edge.node.availableForSale || false,
6800
+ currency: ((_a = edge.node.price) == null ? void 0 : _a.currencyCode) || null,
6801
+ id: edge.node.id,
6802
+ image: ((_b = edge.node.image) == null ? void 0 : _b.url) || null,
6803
+ price: parseFloat((_c = edge.node.price) == null ? void 0 : _c.amount) || null,
6804
+ requiresShipping: edge.node.requiresShipping || false,
6805
+ sale: parseFloat((_d = edge.node.compareAtPrice) == null ? void 0 : _d.amount) || null,
6806
+ title: edge.node.title !== "Default Title" ? edge.node.title : null
6807
+ };
6808
+ };
6809
+ var applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
6810
+ try {
6811
+ const outOfStock = new Set(await shopify.storefront.probeVariantsOutOfStock({
6812
+ domain,
6813
+ storefrontAccessToken,
6814
+ variantIds: variants.map((variant) => variant.id)
6815
+ }));
6816
+ return variants.map((variant) => ({
6817
+ ...variant,
6818
+ availableForSale: variant.availableForSale && !outOfStock.has(variant.id)
6819
+ }));
6820
+ } catch (_) {
6821
+ return variants;
6822
+ }
6823
+ };
6824
+ var sendFeedback = async ({ adminToken, connection, messages, productId, shopify, state }) => {
6825
+ try {
6826
+ await shopify.admin.sendProductResourceFeedback({
6827
+ adminAccessToken: await adminToken(),
6828
+ domain: connection.shop,
6829
+ messages,
6830
+ productId,
6831
+ state
6832
+ });
6833
+ return null;
6834
+ } catch (error) {
6835
+ return error;
6836
+ }
6837
+ };
6838
+ var resolveProduct = async ({ connection, context, read }) => {
6839
+ const byId = (context == null ? void 0 : context.product) ? await read.get({ collection: "product", query: { id: context.product } }) : null;
6840
+ const row2 = byId || ((context == null ? void 0 : context.providerId) && ((context == null ? void 0 : context.shop) || (connection == null ? void 0 : connection.shop)) ? await read.get({
6841
+ collection: "product",
6842
+ query: {
6843
+ "provider.id": context.providerId,
6844
+ "provider.slug": "shopify",
6845
+ "source.domain": context.shop || connection.shop
6846
+ }
6847
+ }) : null);
6848
+ return row2;
6849
+ };
6850
+ var UNAVAILABLE_FEEDBACK = "Drawbridge can't display this product. Check it is active and available in the United States.";
6851
+ var NOT_FOUND = "Shopify product not found";
6852
+ var syncProduct = async ({ adminToken, connection, context, read, settings, shopify }) => {
6853
+ var _a, _b, _c;
6854
+ const row2 = await resolveProduct({ connection, context, read });
6855
+ if (!row2) return { message: "No product row to sync.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
6856
+ const domain = connection.shop;
6857
+ const providerId = (_a = row2.provider) == null ? void 0 : _a.id;
6858
+ const request2 = { product: row2.id, providerId: providerId || null, shop: domain };
6859
+ let product;
6860
+ try {
6861
+ product = await shopify.storefront.getProduct({
6862
+ domain,
6863
+ productId: providerId,
6864
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
6865
+ });
6866
+ } catch (error) {
6867
+ if (error.message !== NOT_FOUND) throw error;
6868
+ const failed2 = await sendFeedback({
6869
+ adminToken,
6870
+ connection,
6871
+ messages: [UNAVAILABLE_FEEDBACK],
6872
+ productId: providerId,
6873
+ shopify,
6874
+ state: "REQUIRES_ACTION"
6875
+ });
6876
+ return {
6877
+ failed: failed2 ? [failed2.message] : void 0,
6878
+ message: "Storefront cannot see this product \u2014 deactivated.",
6879
+ request: request2,
6880
+ result: { status: "inactive" },
6881
+ writes: [
6882
+ {
6883
+ collection: "product",
6884
+ data: { $set: { status: "inactive" } },
6885
+ operation: "update",
6886
+ query: { id: row2.id }
6887
+ },
6888
+ {
6889
+ collection: "advertisement",
6890
+ data: { $set: { status: "drafted" } },
6891
+ multiple: true,
6892
+ operation: "update",
6893
+ query: { product: row2.id }
6894
+ }
6895
+ ]
6896
+ };
6897
+ }
6898
+ const variants = await applyOutOfStock({
6899
+ domain,
6900
+ shopify,
6901
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
6902
+ variants: (((_b = product == null ? void 0 : product.variants) == null ? void 0 : _b.edges) || []).map(toVariant)
6903
+ });
6904
+ const failed = await sendFeedback({
6905
+ adminToken,
6906
+ connection,
6907
+ messages: [],
6908
+ productId: providerId,
6909
+ shopify,
6910
+ state: "ACCEPTED"
6911
+ });
6912
+ return {
6913
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
6914
+ // written above; a product with thousands of variants becomes N short
6915
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
6916
+ enqueues: [{
6917
+ data: { operation: "variants", product: row2.id },
6918
+ name: "sync",
6919
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
6920
+ queue: "product.shopify.variants"
6921
+ }],
6922
+ failed: failed ? [failed.message] : void 0,
6923
+ message: "Synced " + (product.title || "product") + ".",
6924
+ request: request2,
6925
+ result: { status: "active", variants: variants.length },
6926
+ writes: [{
6927
+ collection: "product",
6928
+ data: {
6929
+ $set: {
6930
+ description: product.description || null,
6931
+ image: ((_c = product.featuredImage) == null ? void 0 : _c.url) || null,
6932
+ // A successful sync PROVES channel visibility, which reverses the
6933
+ // deactivation above. Ads stay drafted.
6934
+ status: "active",
6935
+ title: product.title || null,
6936
+ type: product.productType || null,
6937
+ url: domain && product.handle ? "https://" + domain + "/products/" + product.handle : null,
6938
+ variants
6939
+ }
6940
+ },
6941
+ operation: "update",
6942
+ query: { id: row2.id }
6943
+ }]
6944
+ };
6945
+ };
6946
+ var syncVariants = async ({ connection, context, read, settings, shopify }) => {
6947
+ var _a, _b, _c;
6948
+ const row2 = await resolveProduct({ connection, context, read });
6949
+ if (!row2) return { message: "No product row to paginate.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
6950
+ const cursor = (context == null ? void 0 : context.cursor) || null;
6951
+ const request2 = { cursor, product: row2.id };
6952
+ let page;
6953
+ try {
6954
+ page = await shopify.storefront.getProductVariantsPage({
6955
+ cursor,
6956
+ domain: connection.shop,
6957
+ productId: (_a = row2.provider) == null ? void 0 : _a.id,
6958
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
6959
+ });
6960
+ } catch (error) {
6961
+ if (error.message !== NOT_FOUND) throw error;
6962
+ return { message: "Storefront lost this product mid-pagination \u2014 stopping.", request: request2, skipped: true };
6963
+ }
6964
+ const variants = await applyOutOfStock({
6965
+ domain: connection.shop,
6966
+ shopify,
6967
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
6968
+ variants: (page.edges || []).map(toVariant)
6969
+ });
6970
+ return {
6971
+ ...((_b = page.pageInfo) == null ? void 0 : _b.hasNextPage) && { enqueues: [{
6972
+ data: { cursor: page.pageInfo.endCursor, operation: "variants", product: row2.id },
6973
+ name: "page",
6974
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
6975
+ queue: "product.shopify.variants"
6976
+ }] },
6977
+ message: variants.length + " variant(s) on this page.",
6978
+ request: request2,
6979
+ result: { hasNextPage: Boolean((_c = page.pageInfo) == null ? void 0 : _c.hasNextPage), variants: variants.length },
6980
+ writes: [{
6981
+ collection: "product",
6982
+ data: cursor ? { $push: { variants: { $each: variants } } } : { $set: { variants } },
6983
+ operation: "update",
6984
+ query: { id: row2.id }
6985
+ }]
6986
+ };
6987
+ };
4952
6988
  var shopify_default2 = {
4953
6989
  // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
4954
6990
  // a BUTTON LABEL inside the copy object next to a url — the title and the
@@ -5135,8 +7171,8 @@ var shopify_default2 = {
5135
7171
  // string; this answers what is missing from it.
5136
7172
  //
5137
7173
  // `shopify` is injected for the same reason it is everywhere else — this
5138
- // package cannot import @drawbridge/shopify, which depends on it.
5139
- scopes: ({ scope }, { shopify } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
7174
+ // client is imported here and defaulted, never wired in by a caller.
7175
+ scopes: ({ scope }, { shopify = client } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
5140
7176
  // Shopify's install grant is exchanged inside its own app flow, not
5141
7177
  // through the shared OAuth runner.
5142
7178
  token: false
@@ -5150,17 +7186,53 @@ var shopify_default2 = {
5150
7186
  // can reference another), `dispatch` (the caller's own coordinator table,
5151
7187
  // for the hooks that are dispatches).
5152
7188
  commerce: {
5153
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
5154
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
5155
- // exactly why it never became a hook: a hook is reached to have its effects
5156
- // performed, and this one has none to perform. It moves here when the vendor
5157
- // client moves into the manifest and `subdomain.js` stops importing the
5158
- // package directly.
5159
- cart: false,
7189
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
7190
+ // the storefront client from api/route/subdomain.js.
7191
+ //
7192
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
7193
+ // become a hook a hook is usually reached to have its effects performed
7194
+ // and this one has none. That reasoning was wrong: a hook is also the one
7195
+ // place a vendor's client is allowed to be called from, and leaving the
7196
+ // cart out meant a public route importing a vendor SDK directly.
7197
+ //
7198
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
7199
+ // uses for its topic table. Six slots would be six names in the closed
7200
+ // vocabulary for one question — what does this shopper's cart look like
7201
+ // now — asked six ways.
7202
+ //
7203
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
7204
+ // mutations answer "that variant is sold out" as a userError rather than a
7205
+ // transport failure, and the route it replaced already told the two apart.
7206
+ // runHook carries `status` off a thrown error, so it survives to the
7207
+ // caller; the rest of a thrown error's shape does not.
7208
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {}) => {
7209
+ const { cartId, lineIds, lines, operation } = context || {};
7210
+ const { domain, storefrontAccessToken } = settings || {};
7211
+ if (operation === "checkout") {
7212
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
7213
+ return { message: "Checkout token minted.", result: { token } };
7214
+ }
7215
+ const storefront = { domain, fetcher, storefrontAccessToken };
7216
+ try {
7217
+ const cart = await ({
7218
+ create: () => shopify.storefront.cartCreate({ ...storefront, lines }),
7219
+ get: () => shopify.storefront.cartGet({ ...storefront, cartId }),
7220
+ linesAdd: () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
7221
+ linesRemove: () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
7222
+ linesUpdate: () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
7223
+ }[operation] || (() => {
7224
+ throw Object.assign(new Error("Unknown cart operation: " + operation), { status: 400 });
7225
+ }))();
7226
+ return { message: "Cart " + operation + ".", request: { cartId: cartId || null, operation }, result: cart };
7227
+ } catch (error) {
7228
+ if (error == null ? void 0 : error.userError) throw Object.assign(error, { status: 400 });
7229
+ throw error;
7230
+ }
7231
+ },
5160
7232
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
5161
7233
  // one lead — which is what lets an order that redeems it be attributed
5162
7234
  // back.
5163
- code: async ({ connection, context, step }, { adminToken, shopify } = {}) => {
7235
+ code: async ({ connection, context, step }, { adminToken, shopify = client } = {}) => {
5164
7236
  var _a;
5165
7237
  const discount = (_a = step.settings) == null ? void 0 : _a.discount;
5166
7238
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
@@ -5206,7 +7278,7 @@ var shopify_default2 = {
5206
7278
  // customer at the store is a support ticket: the context may already
5207
7279
  // carry the id from an earlier step, the lead may already be linked from
5208
7280
  // an earlier run, and Shopify's own get-or-create settles the rest.
5209
- customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => {
7281
+ customer: async ({ connection, context }, { adminToken, read, shopify = client } = {}) => {
5210
7282
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
5211
7283
  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 };
5212
7284
  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 };
@@ -5256,6 +7328,30 @@ var shopify_default2 = {
5256
7328
  }]
5257
7329
  };
5258
7330
  },
7331
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
7332
+ // rendered rather than stored on the product, because stock is the one
7333
+ // product fact that is stale the instant it is written down.
7334
+ //
7335
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
7336
+ // caller wanted the total; the one that existed summed it inline, and
7337
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
7338
+ // bookkeeping key got counted as a quantity.
7339
+ inventory: async ({ connection, context }, { adminToken, shopify = client } = {}) => {
7340
+ const productId = context == null ? void 0 : context.product;
7341
+ const request2 = { product: productId || null, shop: connection.shop };
7342
+ if (!productId) return { message: "No product id \u2014 nothing to count.", request: request2, result: { total: null }, skipped: true };
7343
+ const adminAccessToken = await adminToken();
7344
+ const byVariant = await shopify.admin.getProductInventory({
7345
+ adminAccessToken,
7346
+ domain: connection.shop,
7347
+ productId
7348
+ });
7349
+ return {
7350
+ message: "Inventory read.",
7351
+ request: request2,
7352
+ result: { total: Object.values(byVariant || {}).reduce((sum, quantity) => sum + quantity, 0) }
7353
+ };
7354
+ },
5259
7355
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
5260
7356
  // attribution genuinely is: an order can reach Drawbridge two ways and
5261
7357
  // they bill differently.
@@ -5576,6 +7672,12 @@ var shopify_default2 = {
5576
7672
  orderId: String(orderId),
5577
7673
  rate,
5578
7674
  shopId: connection.source.id,
7675
+ // WHICH VENDOR IS BEING CHARGED, so the sender does not have
7676
+ // to assume. queue/usage.js named the slug itself and
7677
+ // defaults to it for anything already queued when this
7678
+ // shipped — an app-store vendor billing through its own meter
7679
+ // enqueues the same job with its own slug.
7680
+ slug: connection.slug,
5579
7681
  // The App Events API returns no event id, so one is generated
5580
7682
  // here — the event handle plus the order id — and sent as the
5581
7683
  // event's `reference`. queue/usage.js stamps the same id onto
@@ -5615,10 +7717,28 @@ var shopify_default2 = {
5615
7717
  //
5616
7718
  // The shell has already refused a missing or inactive Shopify connection,
5617
7719
  // so what is left is the two things only this hook can know are wrong.
5618
- product: async ({ connection, context, workflow }, { mintId, read } = {}) => {
7720
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
7721
+ // slots in the closed vocabulary, the same shape inbound.process uses
7722
+ // for its topic table — they are one subject, and a vendor that syncs
7723
+ // products has to answer all three or none.
7724
+ //
7725
+ // record a webhook said a product changed: upsert the row and
7726
+ // queue the pull
7727
+ // sync pull the product from the storefront and write what it
7728
+ // says, including what it says by NOT answering
7729
+ // variants one page of variants, self-chaining
7730
+ //
7731
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
7732
+ // queue/product.js — a shadow implementation of the step this manifest
7733
+ // already declared, importing three of the vendor's client modules from
7734
+ // a worker file.
7735
+ product: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {}) => {
7736
+ const operation = (context == null ? void 0 : context.operation) || "record";
7737
+ if (operation === "sync") return syncProduct({ adminToken, connection, context, read, settings, shopify });
7738
+ if (operation === "variants") return syncVariants({ connection, context, read, settings, shopify });
5619
7739
  const request2 = {
5620
7740
  numericId: (context == null ? void 0 : context.id) || null,
5621
- organizationId: workflow.organization,
7741
+ organizationId: (workflow == null ? void 0 : workflow.organization) || null,
5622
7742
  title: (context == null ? void 0 : context.title) || null
5623
7743
  };
5624
7744
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
@@ -5668,9 +7788,612 @@ var shopify_default2 = {
5668
7788
  }
5669
7789
  },
5670
7790
  contacts: { remove: false, sync: false },
5671
- // The embedded-app surface. Only a vendor whose merchants arrive from its
5672
- // own app store has one.
5673
- install: false,
7791
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
7792
+ // own app store has one, and these thirteen slots are the thirteen routes
7793
+ // that used to live in api/route/shopify-embedded.js.
7794
+ //
7795
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
7796
+ // holds a controller — `read` is the controller's read methods and nothing
7797
+ // else. Where one REFUSES it throws with a status, and the route answers
7798
+ // what it said. The helpers they share are above the manifest.
7799
+ install: {
7800
+ account: {
7801
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
7802
+ // authenticates the merchant once, top-level, and stashes their user
7803
+ // token in `install.token.save`; App Bridge has already proven shop
7804
+ // control by the time this runs.
7805
+ //
7806
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
7807
+ // token: the presented one must BE this shop's one-shot handoff token,
7808
+ // unexpired, and an access or pat record — a refresh or oauth-state
7809
+ // token that happens to resolve to a user must not qualify, which is
7810
+ // the rule a session is held to in drawbridge-api's middleware.
7811
+ link: async ({ context }, { read } = {}) => {
7812
+ var _a;
7813
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7814
+ const token = context == null ? void 0 : context.token;
7815
+ if (!shop || !token) throw refusal("shop and token are required", 400);
7816
+ const now = /* @__PURE__ */ new Date();
7817
+ const record = await read.get({ collection: "shop", query: { shop } });
7818
+ const stashed = (record == null ? void 0 : record.linkToken) ? (_a = decrypt(record.linkToken)) == null ? void 0 : _a.token : null;
7819
+ const expired = !(record == null ? void 0 : record.linkTokenExpiresAt) || new Date(record.linkTokenExpiresAt) <= now;
7820
+ if (!stashed || expired || stashed !== token) throw refusal("Invalid or expired link token", 401);
7821
+ const granted = await read.get({
7822
+ collection: "token",
7823
+ query: {
7824
+ revoked: false,
7825
+ tokenHash: hashToken(token),
7826
+ type: { $in: [tokenTypes.access, tokenTypes.pat] }
7827
+ }
7828
+ });
7829
+ if (!(granted == null ? void 0 : granted.user) || granted.expiresAt && new Date(granted.expiresAt) <= now) {
7830
+ throw refusal("Invalid or expired link token", 401);
7831
+ }
7832
+ const switching = Boolean(record == null ? void 0 : record.user) && record.user !== granted.user;
7833
+ const connections2 = switching ? await read.aggregate({
7834
+ collection: "connection",
7835
+ pipeline: [
7836
+ { $match: { shop, slug: "shopify" } },
7837
+ { $project: { _id: 0, id: 1 } }
7838
+ ]
7839
+ }) : [];
7840
+ return {
7841
+ message: switching ? "Linked to a different Drawbridge account." : "Linked.",
7842
+ request: { shop },
7843
+ result: { connected: true, switched: switching },
7844
+ // ALL OR NONE. A half-done switch is a store linked to the new
7845
+ // account while the old account's connections still serve from it.
7846
+ transaction: true,
7847
+ writes: [
7848
+ ...(connections2 || []).map((connection) => ({
7849
+ collection: "connection",
7850
+ operation: "delete",
7851
+ query: { id: connection.id }
7852
+ })),
7853
+ {
7854
+ collection: "shop",
7855
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: granted.user } },
7856
+ operation: "update",
7857
+ query: { shop }
7858
+ }
7859
+ ]
7860
+ };
7861
+ },
7862
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
7863
+ // the durable link clears; the offline token and the shop record stay,
7864
+ // because the app is still installed and the merchant may link a
7865
+ // different account next.
7866
+ unlink: async ({ context }, { read } = {}) => {
7867
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7868
+ if (!shop) throw refusal("shop is required", 400);
7869
+ const connections2 = await read.aggregate({
7870
+ collection: "connection",
7871
+ pipeline: [
7872
+ { $match: { shop, slug: "shopify" } },
7873
+ { $project: { _id: 0, id: 1 } }
7874
+ ]
7875
+ });
7876
+ return {
7877
+ message: "Unlinked " + (connections2 || []).length + " connection(s).",
7878
+ request: { shop },
7879
+ result: { ok: true },
7880
+ transaction: true,
7881
+ writes: [
7882
+ ...(connections2 || []).map((connection) => ({
7883
+ collection: "connection",
7884
+ operation: "delete",
7885
+ query: { id: connection.id }
7886
+ })),
7887
+ {
7888
+ collection: "shop",
7889
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: null } },
7890
+ operation: "update",
7891
+ query: { shop }
7892
+ }
7893
+ ]
7894
+ };
7895
+ }
7896
+ },
7897
+ organizations: {
7898
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
7899
+ // gate in it came from a line in the route it replaces.
7900
+ add: async ({ context }, { currencies, mintId, read, shopify = client } = {}) => {
7901
+ var _a, _b;
7902
+ const organization = context == null ? void 0 : context.organization;
7903
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7904
+ if (!shop) throw refusal("shop is required", 400);
7905
+ if (!organization) throw refusal("organization is required", 400);
7906
+ const record = await read.get({ collection: "shop", query: { shop } });
7907
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
7908
+ const owned = await merchantOrganizations({ read, user: record.user });
7909
+ const org = (owned || []).find((entry) => entry.id === organization);
7910
+ if (!org) throw refusal("No access to this organization", 403);
7911
+ if (!CONNECTABLE_STATUSES.includes(org.billingStatus)) {
7912
+ throw refusal("This organization can't accept connections right now \u2014 check its billing status in the dashboard", 403);
7913
+ }
7914
+ if (org.connection && org.connection.shop !== shop) {
7915
+ throw refusal("This organization is already connected to another Shopify store", 409);
7916
+ }
7917
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7918
+ if (!(settings == null ? void 0 : settings.accessToken)) {
7919
+ throw refusal("Store is not installed \u2014 open the app in the Shopify admin first", 409);
7920
+ }
7921
+ const plan = await storePlan({ record, shopify });
7922
+ if (plan.unavailable) throw refusal("Couldn't verify the store's plan \u2014 try again shortly", 502);
7923
+ if (!plan.active) throw refusal("Approve a plan before connecting organizations", 402);
7924
+ let storefront = null;
7925
+ if (!record.storefront) {
7926
+ try {
7927
+ const minted2 = await shopify.oauth.createStorefrontToken({
7928
+ adminAccessToken: settings.accessToken,
7929
+ shop
7930
+ });
7931
+ if (minted2) storefront = encrypt({ token: minted2 });
7932
+ } catch (_) {
7933
+ }
7934
+ }
7935
+ let currency = (record == null ? void 0 : record.currency) || null;
7936
+ let source = (record == null ? void 0 : record.source) || null;
7937
+ try {
7938
+ const shopData = await shopify.oauth.getShop({ adminAccessToken: settings.accessToken, shop });
7939
+ if (shopData) {
7940
+ currency = String(shopData.currency || "").toLowerCase() || null;
7941
+ source = { domain: shopData.myshopifyDomain, id: String(shopData.id), label: shopData.name };
7942
+ }
7943
+ } catch (_) {
7944
+ }
7945
+ if (currency && currencies && !currencies.includes(currency)) {
7946
+ throw refusal("This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.", 422);
7947
+ }
7948
+ const minted = ((_a = org.connection) == null ? void 0 : _a.id) ? null : mintId();
7949
+ const connectionId = ((_b = org.connection) == null ? void 0 : _b.id) || minted.id;
7950
+ return {
7951
+ message: "Connected " + org.name + " to " + shop + ".",
7952
+ request: { organization, shop },
7953
+ result: { ok: true },
7954
+ transaction: true,
7955
+ writes: [
7956
+ ...storefront ? [{
7957
+ collection: "shop",
7958
+ data: { $set: { storefront } },
7959
+ operation: "update",
7960
+ query: { shop }
7961
+ }] : [],
7962
+ {
7963
+ collection: "connection",
7964
+ data: {
7965
+ $set: {
7966
+ currency,
7967
+ errors: [],
7968
+ // The token of record lives on the shared `shop` row; this
7969
+ // is the pointer that satisfies the required `settings`
7970
+ // string, and `auth.install.shared` declares how to read it.
7971
+ settings: encrypt({ ref: "shop", shop }),
7972
+ shop,
7973
+ status: "pending",
7974
+ ...source && { source }
7975
+ },
7976
+ $setOnInsert: {
7977
+ ...minted && { _id: minted._id, id: minted.id },
7978
+ feature: "organization:connection:shopify",
7979
+ organization,
7980
+ slug: "shopify"
7981
+ }
7982
+ },
7983
+ operation: "update",
7984
+ options: { upsert: true },
7985
+ query: { organization, slug: "shopify" }
7986
+ },
7987
+ {
7988
+ collection: "organization",
7989
+ data: {
7990
+ // $addToSet UNCONDITIONALLY. The route this replaces only
7991
+ // registered the refs when the upsert INSERTED, which meant an
7992
+ // org whose refs had drifted stayed drifted through every
7993
+ // re-link. Adding a member that is already there is a no-op,
7994
+ // so doing it every time costs nothing and repairs the drift.
7995
+ $addToSet: {
7996
+ "connections.groups": "ecommerce",
7997
+ "connections.ids": connectionId,
7998
+ "connections.keys": "shopify"
7999
+ },
8000
+ $set: { billingProvider: "shopify" }
8001
+ },
8002
+ operation: "update",
8003
+ query: { id: organization }
8004
+ }
8005
+ ]
8006
+ };
8007
+ },
8008
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
8009
+ // store's live plan, and the products the merchant has to act on.
8010
+ //
8011
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
8012
+ // `add` would refuse it — a list that offers what the next call rejects
8013
+ // is worse than a shorter one.
8014
+ list: async ({ context }, { conversionRate, planTitle, read, shopify = client } = {}) => {
8015
+ var _a;
8016
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8017
+ if (!shop) throw refusal("shop is required", 400);
8018
+ const record = await read.get({ collection: "shop", query: { shop } });
8019
+ if (!(record == null ? void 0 : record.user)) {
8020
+ return {
8021
+ message: "No account linked to this store.",
8022
+ request: { shop },
8023
+ result: {
8024
+ account: null,
8025
+ connected: false,
8026
+ organizations: [],
8027
+ plan: { active: false, name: null },
8028
+ unavailableProducts: [],
8029
+ unavailableProductsTotal: 0
8030
+ }
8031
+ };
8032
+ }
8033
+ const owned = await merchantOrganizations({ read, user: record.user });
8034
+ const organizations = (owned || []).filter((org) => !org.connection || org.connection.shop === shop).map((org) => {
8035
+ var _a2;
8036
+ return {
8037
+ // The org's OWN billing status decides whether `add` accepts it.
8038
+ // Sent up so the app can disable Connect and name the reason
8039
+ // rather than letting the merchant click into a 403 —
8040
+ // listed-but-blocked beats hidden, because an org vanishing from
8041
+ // the list is indistinguishable from a bug.
8042
+ billingStatus: org.billingStatus || null,
8043
+ connectable: CONNECTABLE_STATUSES.includes(org.billingStatus),
8044
+ connected: Boolean(org.connection),
8045
+ conversion: conversionRate(org.subscription),
8046
+ id: org.id,
8047
+ name: org.name,
8048
+ plan: planTitle(org.subscription),
8049
+ status: ((_a2 = org.connection) == null ? void 0 : _a2.status) || null
8050
+ };
8051
+ });
8052
+ const connections2 = (owned || []).filter((org) => {
8053
+ var _a2;
8054
+ return ((_a2 = org.connection) == null ? void 0 : _a2.shop) === shop && org.connection.id;
8055
+ }).map((org) => org.connection.id);
8056
+ const [account, plan, unavailable] = await Promise.all([
8057
+ read.get({ collection: "user", query: { id: record.user } }),
8058
+ storePlan({ record, shopify }),
8059
+ connections2.length ? read.aggregate({
8060
+ collection: "product",
8061
+ pipeline: [
8062
+ { $match: { connections: { $in: connections2 }, "source.domain": shop, status: "inactive" } },
8063
+ // $facet so the app can name the first 25 AND report a
8064
+ // truthful total: listing 25 of 40 under "25 products need
8065
+ // attention" would understate the problem.
8066
+ {
8067
+ $facet: {
8068
+ items: [{ $sort: { title: 1 } }, { $limit: 25 }, { $project: { _id: 0, id: 1, title: 1 } }],
8069
+ total: [{ $count: "count" }]
8070
+ }
8071
+ }
8072
+ ]
8073
+ }) : []
8074
+ ]);
8075
+ const facet = (unavailable || [])[0] || {};
8076
+ if (plan.active && plan.metered !== true) {
8077
+ const stamped = await read.aggregate({
8078
+ collection: "connection",
8079
+ pipeline: [
8080
+ { $match: { shop, slug: "shopify" } },
8081
+ { $project: { _id: 0, "source.metered": 1 } }
8082
+ ]
8083
+ });
8084
+ const verdicts = (stamped || []).map((row2) => {
8085
+ var _a2;
8086
+ return (_a2 = row2 == null ? void 0 : row2.source) == null ? void 0 : _a2.metered;
8087
+ });
8088
+ if (verdicts.some((verdict) => typeof verdict === "string")) plan.metered = true;
8089
+ else if (verdicts.some((verdict) => verdict === null)) plan.metered = false;
8090
+ }
8091
+ return {
8092
+ message: organizations.length + " organization(s) for this store.",
8093
+ request: { shop },
8094
+ result: {
8095
+ account: { email: (account == null ? void 0 : account.email) || null },
8096
+ connected: true,
8097
+ organizations,
8098
+ plan,
8099
+ unavailableProducts: (facet.items || []).map((product) => ({
8100
+ id: product.id,
8101
+ title: product.title || "Untitled product"
8102
+ })),
8103
+ // The total can exceed the 25 listed above, and has to.
8104
+ unavailableProductsTotal: ((_a = (facet.total || [])[0]) == null ? void 0 : _a.count) || 0
8105
+ }
8106
+ };
8107
+ },
8108
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
8109
+ // else: every dependent — products, ads, steps, workflows, and the org's
8110
+ // own connection refs — is torn down by drawbridge-sync's connection
8111
+ // change stream. The shared token and the other orgs are untouched.
8112
+ remove: async ({ context }, { read } = {}) => {
8113
+ const organization = context == null ? void 0 : context.organization;
8114
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8115
+ if (!shop) throw refusal("shop is required", 400);
8116
+ if (!organization) throw refusal("organization is required", 400);
8117
+ const record = await read.get({ collection: "shop", query: { shop } });
8118
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8119
+ const owned = await merchantOrganizations({ read, user: record.user });
8120
+ if (!(owned || []).some((entry) => entry.id === organization)) {
8121
+ throw refusal("No access to this organization", 403);
8122
+ }
8123
+ const connection = await read.get({
8124
+ collection: "connection",
8125
+ query: { organization, shop, slug: "shopify" }
8126
+ });
8127
+ if (!connection) throw refusal("No connection to remove", 404);
8128
+ return {
8129
+ message: "Disconnected.",
8130
+ request: { organization, shop },
8131
+ result: { ok: true },
8132
+ writes: [{ collection: "connection", operation: "delete", query: { id: connection.id } }]
8133
+ };
8134
+ }
8135
+ },
8136
+ plan: {
8137
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
8138
+ // and falls back to "Choose a plan".
8139
+ cancel: ({ context }, { read, shopify = client } = {}) => releaseApprovals({ context, read, shopify }),
8140
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
8141
+ // order step and run on a retrying queue, so this may be called
8142
+ // several times for one order — which is safe because the idempotency
8143
+ // key is PERMANENT for billing events and Shopify settles the repeat.
8144
+ //
8145
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
8146
+ // is a real strengthening of what it replaced: drawbridge-sync compared
8147
+ // the job's `handle` against the job's `transaction` — two fields from
8148
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
8149
+ // handle is the one string that decides whether the event bills at all,
8150
+ // and this is the file that publishes it.
8151
+ //
8152
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
8153
+ // idempotency key on an event that misclassifies or mistraces. The
8154
+ // throw rides the queue's normal retry path and the abandoned-job alert
8155
+ // pages on exhaustion.
8156
+ //
8157
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
8158
+ // which is always inside the merchant's current billing cycle at the
8159
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
8160
+ // purchase time reads more correct and is strictly worse: a retry
8161
+ // backoff crossing a cycle boundary would carry a closed-cycle
8162
+ // timestamp, Shopify would 202-accept, silently drop it, and the
8163
+ // commission would be lost. Send-time's worst case is benign — the
8164
+ // charge lands in the next cycle, but it lands.
8165
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify = client } = {}) => {
8166
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
8167
+ const handle = (context == null ? void 0 : context.handle) || manifest.auth.install.billing.handle;
8168
+ if (!transaction || !String(transaction).startsWith(handle + ".")) {
8169
+ throw new Error("shopify.usage.billing.handle.mismatch on order " + orderId + ": transaction " + (transaction || "null") + " does not carry handle " + handle + " \u2014 refusing to send");
8170
+ }
8171
+ await shopify.billing.sendAppEvent({
8172
+ clientId,
8173
+ clientSecret,
8174
+ eventHandle: handle,
8175
+ idempotencyKey,
8176
+ // The Shopify-side event record names the order that produced it.
8177
+ reference: transaction,
8178
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
8179
+ // send. `revision` suffixes the idempotency key, which is what
8180
+ // makes a second attempt possible once a key has been consumed;
8181
+ // the encoding is owned by sendAppEvent so no caller builds keys
8182
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
8183
+ // path must never do — see the note above — and the recovery
8184
+ // migration must, because it is resending orders from a closed
8185
+ // window and bounds its own run to the merchant's current cycle.
8186
+ ...revision && { revision },
8187
+ ...timestamp && { timestamp },
8188
+ shopId,
8189
+ value
8190
+ });
8191
+ return {
8192
+ message: "Usage charge sent for order " + orderId + ".",
8193
+ request: { order: order || null, orderId: orderId || null, shopId: shopId || null, value },
8194
+ result: { transaction: String(transaction) },
8195
+ // TRACEABILITY. The App Events API returns no event id, so the
8196
+ // trace key into Shopify's event logs is the transaction id the
8197
+ // commerce.order hook minted and this sent as `reference`.
8198
+ // `amount` is the per-order usage value in cents, mirroring the
8199
+ // Stripe path's marker.
8200
+ ...order && { writes: [{
8201
+ collection: "order",
8202
+ data: {
8203
+ $set: {
8204
+ billed: {
8205
+ amount: value,
8206
+ date: /* @__PURE__ */ new Date(),
8207
+ rate: rate || 0,
8208
+ transaction: String(transaction)
8209
+ }
8210
+ }
8211
+ },
8212
+ operation: "update",
8213
+ query: { id: order }
8214
+ }] }
8215
+ };
8216
+ },
8217
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
8218
+ // be released before the pricing page will offer the plan again — but
8219
+ // the intent is a thirty-second round trip rather than a departure.
8220
+ //
8221
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
8222
+ // needs one. The marker has to be persisted BEFORE the approval is
8223
+ // released: it is what tells sync's CANCELLED handler to hold the pause
8224
+ // — no connection error, no owner email — and start a timed recheck that
8225
+ // pauses honestly if the merchant abandons. A described write lands
8226
+ // after the hook returns, which is after the cancel, and a marker
8227
+ // written then can race the very webhook it exists to soften. Same
8228
+ // precedent as `resolveContact`: the one write a description cannot
8229
+ // carry.
8230
+ reselect: async ({ context }, { mark, read, shopify = client } = {}) => {
8231
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8232
+ if (!shop) throw refusal("shop is required", 400);
8233
+ await mark({ shop });
8234
+ return releaseApprovals({ context, read, shopify });
8235
+ },
8236
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
8237
+ // action — the other two slots here are — but the same subject, and
8238
+ // the reason it is a declared hook rather than a helper is that the
8239
+ // answer was being derived in two places: `lifecycle.health` below,
8240
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
8241
+ // stated in its own comment that it matched this one and did not.
8242
+ //
8243
+ // THREE ANSWERS, and the caller must keep them apart:
8244
+ //
8245
+ // a string PROVEN. The charge id or usage line id, stamped on
8246
+ // the connection as the evidence.
8247
+ // null PROBED, and nothing is accruing.
8248
+ // undefined NO VERDICT — the probe could not run. `probed` says
8249
+ // which, because undefined does not survive JSON.
8250
+ verify: async ({ context }, { adminToken, read, shopify = client } = {}) => {
8251
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8252
+ if (!shop) throw refusal("shop is required", 400);
8253
+ const adminAccessToken = await adminToken();
8254
+ if (!adminAccessToken) {
8255
+ return { message: "No admin token for this store \u2014 no verdict.", request: { shop }, result: { metered: null, probed: false }, skipped: true };
8256
+ }
8257
+ const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
8258
+ const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
8259
+ const { ledger, metered } = await meterVerdict({
8260
+ adminAccessToken,
8261
+ ledgerFloor: new Date(Date.now() - LEDGER_WINDOW_MS),
8262
+ partner,
8263
+ shop,
8264
+ shopId: (context == null ? void 0 : context.shopId) || null,
8265
+ shopify
8266
+ });
8267
+ return {
8268
+ message: typeof metered === "string" ? "Billing is metered." : "No meter evidence for this store.",
8269
+ request: { shop },
8270
+ // `probed` is the LEDGER, not the call: a store whose Partner
8271
+ // credentials are absent was not asked, and reporting that as
8272
+ // "probed, nothing accruing" would stamp an unmetered verdict on
8273
+ // a store nobody looked at.
8274
+ result: { metered: metered ?? null, probed: ledger || typeof metered === "string" }
8275
+ };
8276
+ }
8277
+ },
8278
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
8279
+ // persists the offline session on token exchange and refresh; it lives
8280
+ // encrypted on the shared `shop` record, which is the same row every
8281
+ // organization's connection to this store points at.
8282
+ session: {
8283
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
8284
+ // invalidation and not only on uninstall, so hard-deleting the record
8285
+ // would orphan every org connection pointing at it — token resolution
8286
+ // silently nulls — and destroy the durable account link and the
8287
+ // storefront token with it. The next embedded load re-runs token
8288
+ // exchange. The full record delete belongs to the uninstall webhook.
8289
+ clear: async ({ context }) => {
8290
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8291
+ if (!shop) throw refusal("shop is required", 400);
8292
+ return {
8293
+ message: "Session cleared.",
8294
+ request: { shop },
8295
+ result: { ok: true },
8296
+ writes: [{
8297
+ collection: "shop",
8298
+ data: { $set: { settings: null } },
8299
+ operation: "update",
8300
+ query: { shop }
8301
+ }]
8302
+ };
8303
+ },
8304
+ read: async ({ context }, { read } = {}) => {
8305
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8306
+ if (!shop) throw refusal("shop is required", 400);
8307
+ const record = await read.get({ collection: "shop", query: { shop } });
8308
+ if (!(record == null ? void 0 : record.settings)) throw refusal("No session for this store", 404);
8309
+ return {
8310
+ message: "Session read.",
8311
+ request: { shop },
8312
+ result: { session: decrypt(record.settings) }
8313
+ };
8314
+ },
8315
+ save: async ({ context }) => {
8316
+ const session = context == null ? void 0 : context.session;
8317
+ const shop = sanitizeDomain(session == null ? void 0 : session.shop);
8318
+ if (!shop || !(session == null ? void 0 : session.accessToken)) throw refusal("session.shop and session.accessToken are required", 400);
8319
+ return {
8320
+ message: "Session saved.",
8321
+ request: { shop },
8322
+ result: { ok: true },
8323
+ writes: [{
8324
+ collection: "shop",
8325
+ data: {
8326
+ $set: { settings: encrypt(session), shop },
8327
+ $setOnInsert: { status: "pending" }
8328
+ },
8329
+ operation: "update",
8330
+ options: { upsert: true },
8331
+ query: { shop }
8332
+ }]
8333
+ };
8334
+ }
8335
+ },
8336
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
8337
+ // a first-party cookie; the embedded App Home cannot read that cookie from
8338
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
8339
+ // user token here keyed by shop, and App Home reads it once and clears it.
8340
+ //
8341
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
8342
+ // mid-handoff. Short-lived and encrypted.
8343
+ token: {
8344
+ clear: async ({ context }) => {
8345
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8346
+ if (!shop) throw refusal("shop is required", 400);
8347
+ return {
8348
+ message: "Link token cleared.",
8349
+ request: { shop },
8350
+ result: { ok: true },
8351
+ writes: [{
8352
+ collection: "shop",
8353
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null } },
8354
+ operation: "update",
8355
+ query: { shop }
8356
+ }]
8357
+ };
8358
+ },
8359
+ read: async ({ context }, { read } = {}) => {
8360
+ var _a;
8361
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8362
+ if (!shop) throw refusal("shop is required", 400);
8363
+ const record = await read.get({ collection: "shop", query: { shop } });
8364
+ const expiresAt = (record == null ? void 0 : record.linkTokenExpiresAt) ? new Date(record.linkTokenExpiresAt) : null;
8365
+ if (!(record == null ? void 0 : record.linkToken) || !expiresAt || expiresAt <= /* @__PURE__ */ new Date()) {
8366
+ throw refusal("No link token for this store", 404);
8367
+ }
8368
+ return {
8369
+ message: "Link token read.",
8370
+ request: { shop },
8371
+ result: { token: ((_a = decrypt(record.linkToken)) == null ? void 0 : _a.token) ?? null }
8372
+ };
8373
+ },
8374
+ save: async ({ context }) => {
8375
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8376
+ const token = context == null ? void 0 : context.token;
8377
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8378
+ return {
8379
+ message: "Link token saved.",
8380
+ request: { shop },
8381
+ result: { ok: true },
8382
+ writes: [{
8383
+ collection: "shop",
8384
+ data: {
8385
+ $set: {
8386
+ linkToken: encrypt({ token }),
8387
+ linkTokenExpiresAt: new Date(Date.now() + LINK_TOKEN_TTL_MS)
8388
+ }
8389
+ },
8390
+ operation: "update",
8391
+ query: { shop }
8392
+ }]
8393
+ };
8394
+ }
8395
+ }
8396
+ },
5674
8397
  // Drawbridge sends its own notification email and SMS — see the private
5675
8398
  // `drawbridge` manifest. A vendor answering these would be a second sender,
5676
8399
  // which is the arrangement the platform sender replaced.
@@ -5732,8 +8455,8 @@ var shopify_default2 = {
5732
8455
  // access token still works, reconciles the scopes the store granted
5733
8456
  // against the ones the app now needs, and queues a webhook
5734
8457
  // reconciliation.
5735
- health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
5736
- var _a, _b, _c, _d, _e, _f;
8458
+ health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {}) => {
8459
+ var _a, _b, _c, _d;
5737
8460
  const request2 = {
5738
8461
  connectionId: workflow.connection,
5739
8462
  organizationId: workflow.organization,
@@ -5770,33 +8493,26 @@ var shopify_default2 = {
5770
8493
  shopCurrency = String(shopData.currency || "").toLowerCase() || null;
5771
8494
  sourceRepaired = true;
5772
8495
  }
5773
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
5774
- adminAccessToken,
5775
- domain: connection.shop
5776
- });
5777
- const usageLine = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
5778
- if (usageLine) metered = usageLine;
5779
8496
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
5780
8497
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
5781
- if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
5782
- const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
5783
- const accruals = await shopify.partner.getUsageChargeEvents({
5784
- appId: "gid://shopify/App/" + partner.partnerAppId,
5785
- first: 10,
5786
- occurredAtMin: ledgerFloor.toISOString(),
5787
- organizationId: partner.partnerOrgId,
5788
- partnerToken: partner.partnerToken,
5789
- shopId: "gid://shopify/Shop/" + source.id
5790
- });
5791
- const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
5792
- const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
5793
- if (accrued) metered = String(accrued.chargeId || accrued.id);
5794
- const stampedAt = (_c = boundaries.find(
8498
+ const ledgerFloor = new Date(Date.now() - LEDGER_WINDOW_MS);
8499
+ const verdict = await meterVerdict({
8500
+ adminAccessToken,
8501
+ ledgerFloor,
8502
+ partner,
8503
+ shop: connection.shop,
8504
+ shopId: source == null ? void 0 : source.id,
8505
+ shopify
8506
+ });
8507
+ if (verdict.metered !== void 0) metered = verdict.metered;
8508
+ const { boundaries } = verdict;
8509
+ if (boundaries.length) {
8510
+ const stampedAt = (_a = boundaries.find(
5795
8511
  (event) => {
5796
8512
  var _a2;
5797
8513
  return String(event.chargeId || event.id) === String(((_a2 = connection.source) == null ? void 0 : _a2.metered) || "");
5798
8514
  }
5799
- )) == null ? void 0 : _c.occurredAt;
8515
+ )) == null ? void 0 : _a.occurredAt;
5800
8516
  reconciliation = [];
5801
8517
  let windowStart = ledgerFloor;
5802
8518
  for (const boundary of boundaries) {
@@ -5846,7 +8562,7 @@ var shopify_default2 = {
5846
8562
  }
5847
8563
  });
5848
8564
  } else {
5849
- (_d = logger == null ? void 0 : logger.error) == null ? void 0 : _d.call(logger, new Error(
8565
+ (_b = logger == null ? void 0 : logger.error) == null ? void 0 : _b.call(logger, new Error(
5850
8566
  "shopify.usage.reconciliation.mismatch on shop " + connection.shop + ": charge " + charge + " charged " + charged + " cents but its window holds " + billed + " cents across " + orders.length + " billed orders \u2014 each cent of difference is commission that never charged"
5851
8567
  ), {
5852
8568
  extra: {
@@ -5865,13 +8581,13 @@ var shopify_default2 = {
5865
8581
  }
5866
8582
  } catch (probeError) {
5867
8583
  billingProbeFailed = true;
5868
- (_e = logger == null ? void 0 : logger.warn) == null ? void 0 : _e.call(logger, "shopify.health.billing.probe.failed", {
8584
+ (_c = logger == null ? void 0 : logger.warn) == null ? void 0 : _c.call(logger, "shopify.health.billing.probe.failed", {
5869
8585
  connectionId: connection.id,
5870
8586
  message: probeError == null ? void 0 : probeError.message,
5871
8587
  shop: connection.shop
5872
8588
  });
5873
8589
  }
5874
- if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection.source) == null ? void 0 : _f.metered) ?? void 0)) {
8590
+ if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection.source) == null ? void 0 : _d.metered) ?? void 0)) {
5875
8591
  writes.push({
5876
8592
  collection: "connection",
5877
8593
  data: {
@@ -6006,13 +8722,13 @@ var shopify_default2 = {
6006
8722
  // They are the same two questions every other vendor answers through
6007
8723
  // resources.*, so they answer them the same way now.
6008
8724
  //
6009
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
8725
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
6010
8726
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
6011
8727
  // and nothing else — no controller, no collection access. Resolving the
6012
8728
  // credential is the caller's job because it is Drawbridge's job: the
6013
8729
  // admin token refreshes and writes itself back, which is service work,
6014
8730
  // not vendor work.
6015
- products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
8731
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify = client } = {}) => {
6016
8732
  var _a, _b, _c, _d;
6017
8733
  const products = await shopify.storefront.getProducts({
6018
8734
  cursor,
@@ -6034,7 +8750,7 @@ var shopify_default2 = {
6034
8750
  }
6035
8751
  };
6036
8752
  },
6037
- promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
8753
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify = client } = {}) => {
6038
8754
  var _a, _b;
6039
8755
  const discounts = await shopify.admin.getDiscounts({
6040
8756
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -6185,6 +8901,13 @@ var shopify_default2 = {
6185
8901
  key: "Shopify Order Tracking",
6186
8902
  queue: "connection",
6187
8903
  system: true,
8904
+ // THE TRIGGER THE REGISTER JOB HAND-WROTE. It is byte-identical to
8905
+ // the one on every stored workflow, because these documents exist on
8906
+ // prod with thousands of runs behind them and provisioning is
8907
+ // idempotent on (connection, system, title) — a different trigger
8908
+ // here would not be picked up by the existing rows anyway, and a
8909
+ // different TITLE would provision a second workflow for every store.
8910
+ trigger: { event: "shopify.order.create", type: "webhook" },
6188
8911
  type: "step.commerce.order.record"
6189
8912
  })
6190
8913
  },
@@ -6195,6 +8918,15 @@ var shopify_default2 = {
6195
8918
  key: "Shopify Product Sync",
6196
8919
  queue: "connection",
6197
8920
  system: true,
8921
+ // `shopify.product.update`, NOT `product.insert`. The plan named the
8922
+ // latter, and it is the right long-term answer — a Drawbridge product
8923
+ // row appearing should pull the vendor's copy, rather than
8924
+ // stream/product.js branching on the slug and enqueueing a
8925
+ // vendor-named queue. But a workflow carries ONE trigger, this one is
8926
+ // stored on prod against the webhook, and changing a live workflow's
8927
+ // trigger is a data migration. So the declaration matches what exists
8928
+ // and `product.insert` stays a separate, migration-gated change.
8929
+ trigger: { event: "shopify.product.update", type: "webhook" },
6198
8930
  type: "step.commerce.product.sync"
6199
8931
  })
6200
8932
  }
@@ -6216,26 +8948,37 @@ var shopify_default2 = {
6216
8948
  type: "step.connection.health.check"
6217
8949
  })
6218
8950
  },
6219
- // Audit-only. The "Shopify Token Activity" system workflow lists these
6220
- // for descriptive grouping, but its audit step docs are written manually
6221
- // at OAuth time — the workflow is never dispatched. Routing is declared
6222
- // defensively so that if it ever IS dispatched, the job lands on a real
6223
- // queue and the handler lookup misses cleanly instead of throwing
6224
- // "Unknown step type".
8951
+ // THE ONE WORKFLOW THAT CARRIES TWO STEPS, and the shape the plan called
8952
+ // the single genuinely new thing in it.
8953
+ //
8954
+ // Every other system step is its own workflow, titled by its own `key`.
8955
+ // These two are "Shopify Token Activity", one row in the merchant's list
8956
+ // with both steps in it — so they declare `workflow`, which names the
8957
+ // title, the trigger and the ORDER, and the provisioner groups on it.
8958
+ // Without that key the pair could only be provisioned by the hand-written
8959
+ // spec in drawbridge-sync's register job, which is exactly why that spec
8960
+ // outlived every other one.
8961
+ //
8962
+ // AUDIT-ONLY. The step documents are written manually at OAuth time and
8963
+ // the workflow is never dispatched; `queue` is declared defensively so
8964
+ // that if it ever IS, the job lands on a real queue and the handler lookup
8965
+ // misses cleanly rather than throwing "Unknown step type".
6225
8966
  token: {
6226
8967
  exchange: () => ({
6227
8968
  description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
6228
8969
  key: "Shopify Token Exchange",
6229
8970
  queue: "connection",
6230
8971
  system: true,
6231
- type: "step.connection.token.exchange"
8972
+ type: "step.connection.token.exchange",
8973
+ workflow: { key: "Shopify Token Activity", order: 0, trigger: { event: "shopify.token", type: "event" } }
6232
8974
  }),
6233
8975
  refresh: () => ({
6234
8976
  description: "Records a token rotation. Audit only \u2014 never dispatched.",
6235
8977
  key: "Shopify Token Refresh",
6236
8978
  queue: "connection",
6237
8979
  system: true,
6238
- type: "step.connection.token.refresh"
8980
+ type: "step.connection.token.refresh",
8981
+ workflow: { key: "Shopify Token Activity", order: 1, trigger: { event: "shopify.token", type: "event" } }
6239
8982
  })
6240
8983
  }
6241
8984
  }
@@ -6539,14 +9282,15 @@ var webhook_default = {
6539
9282
  "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
6540
9283
  "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."
6541
9284
  ],
6542
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6543
- // The dashboard already carries generic reasons for every way an
6544
- // authorization can fail declined, expired state, a vendor we are not set
6545
- // up for and a sentence here is an ADDITION to those, for a vendor whose
6546
- // failures need saying differently. This one's do not.
9285
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
9286
+ // nothing is authorized against a third party, so neither `denied` nor
9287
+ // `invalid` has a state it could describe. The failures this connection
9288
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
9289
+ // those reach them through the connection's own errors rather than through
9290
+ // connect copy.
6547
9291
  //
6548
- // Answered rather than omitted, because `errors` existed on two of six
6549
- // manifests and there was no way to tell "nothing to add" from "nobody
9292
+ // `false` is the recorded decision, not silence: `errors` existed on two of
9293
+ // six manifests and there was no way to tell "nothing to add" from "nobody
6550
9294
  // wrote it".
6551
9295
  errors: false,
6552
9296
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -6690,7 +9434,7 @@ var webhook_default = {
6690
9434
  // The connect prompt only where connecting is not already the card's whole
6691
9435
  // story: a disconnected or errored document renders a Connect action itself,
6692
9436
  // and a task repeating it talks over the button.
6693
- tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
9437
+ tasks: ({ settings, status } = {}) => status === "error" ? [] : (settings == null ? void 0 : settings.secret) ? [
6694
9438
  {
6695
9439
  message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
6696
9440
  title: "Requests must be verified",
@@ -6777,6 +9521,77 @@ var attentive_default3 = {
6777
9521
  }
6778
9522
  };
6779
9523
 
9524
+ // lib/connections/icons/brightdata.js
9525
+ var brightdata_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
9526
+ <rect width="500" height="500" fill="#0F58FF"/>
9527
+ <circle cx="250" cy="250" r="52" fill="white"/>
9528
+ <rect x="234" y="70" width="32" height="96" rx="16" fill="white"/>
9529
+ <rect x="234" y="334" width="32" height="96" rx="16" fill="white"/>
9530
+ <rect x="70" y="234" width="96" height="32" rx="16" fill="white"/>
9531
+ <rect x="334" y="234" width="96" height="32" rx="16" fill="white"/>
9532
+ <rect x="117" y="140" width="32" height="96" rx="16" transform="rotate(-45 117 140)" fill="white"/>
9533
+ <rect x="304" y="327" width="32" height="96" rx="16" transform="rotate(-45 304 327)" fill="white"/>
9534
+ <rect x="360" y="117" width="32" height="96" rx="16" transform="rotate(45 360 117)" fill="white"/>
9535
+ <rect x="173" y="304" width="32" height="96" rx="16" transform="rotate(45 173 304)" fill="white"/>
9536
+ </svg>`;
9537
+
9538
+ // lib/connections/vendors/brightdata.js
9539
+ var brightdata_default2 = {
9540
+ fields: [
9541
+ {
9542
+ credential: "BRIGHTDATA_BROWSER_URI",
9543
+ input: "password",
9544
+ key: "browserUri",
9545
+ // A wss:// endpoint with the account's user and password in its
9546
+ // authority, which is why it is a password field rather than a url one:
9547
+ // the whole string is the credential.
9548
+ label: "Scraping Browser endpoint",
9549
+ message: "The wss:// Scraping Browser endpoint, carrying its own credentials. Used to render pages that need a real browser.",
9550
+ redact: true,
9551
+ required: true
9552
+ },
9553
+ {
9554
+ credential: "BRIGHTDATA_UNLOCKER_API_KEY",
9555
+ input: "password",
9556
+ key: "unlockerApiKey",
9557
+ label: "Web Unlocker API key",
9558
+ // NO EXPIRY FIELD, and that is a decision rather than an omission. Bright
9559
+ // Data offers an `Unlimited` expiry and recommends against it; Drawbridge
9560
+ // takes the unlimited key deliberately, because there is no way to rotate
9561
+ // this programmatically and a date nothing can act on is a date that only
9562
+ // surfaces after it has already broken scraping. If a fixed expiry is ever
9563
+ // taken instead, the field to add is a stored date and a warning ahead of
9564
+ // it — not a rotation, which the vendor cannot support.
9565
+ message: "Bright Data account settings \u2192 API keys. Taken with an unlimited expiry: their API offers no way to rotate a key, so a dated one can only ever break scraping silently.",
9566
+ redact: true,
9567
+ required: true
9568
+ },
9569
+ {
9570
+ credential: "BRIGHTDATA_UNLOCKER_ZONE",
9571
+ input: "text",
9572
+ key: "unlockerZone",
9573
+ label: "Web Unlocker zone",
9574
+ // Not secret — a zone is a label on the account, and it is shown rather
9575
+ // than redacted so an operator can confirm which one is in use without
9576
+ // clearing the field to read it.
9577
+ message: "The zone name the Unlocker requests run against.",
9578
+ required: true
9579
+ }
9580
+ ],
9581
+ icon: brightdata_default,
9582
+ name: "Bright Data",
9583
+ slug: "brightdata",
9584
+ // WHERE THE FACTS CAME FROM, read 2026-09-16.
9585
+ urls: {
9586
+ api: "https://docs.brightdata.com/api-reference/authentication",
9587
+ dashboard: "https://brightdata.com/cp/setting/users",
9588
+ // Bright Data has no scope model — a key carries the account's own access —
9589
+ // so there is nothing to link. `false` is the recorded answer rather than a
9590
+ // missing key.
9591
+ scopes: false
9592
+ }
9593
+ };
9594
+
6780
9595
  // lib/connections/vendors/hubspot.js
6781
9596
  var hubspot_default2 = {
6782
9597
  // WHAT AN ADMIN TYPES on the provider screen. `credential` names the env var a
@@ -7152,7 +9967,7 @@ var checkIcon = (owner, icon) => {
7152
9967
  }
7153
9968
  };
7154
9969
  var vendors = Object.freeze(Object.fromEntries(
7155
- [attentive_default3, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
9970
+ [attentive_default3, brightdata_default2, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
7156
9971
  ));
7157
9972
  var buildVendor = (vendor) => {
7158
9973
  if (!(vendor == null ? void 0 : vendor.slug)) throw new Error("A vendor needs a slug");
@@ -7179,7 +9994,7 @@ for (const [slug2, vendor] of Object.entries(vendors)) {
7179
9994
  buildVendor(vendor);
7180
9995
  }
7181
9996
  var build = (manifest) => {
7182
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x;
9997
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
7183
9998
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
7184
9999
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
7185
10000
  if (manifest == null ? void 0 : manifest.private) throw new Error(manifest.slug + " declares private \u2014 that is feature : false now");
@@ -7445,11 +10260,22 @@ var build = (manifest) => {
7445
10260
  manifest.slug + " step " + type2 + " may be triggered by " + trigger + ", which is not a trigger \u2014 one of " + Object.keys(TRIGGERS).join(", ")
7446
10261
  );
7447
10262
  }
7448
- if (declared2.trigger) {
7449
- const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === declared2.trigger.event && TRIGGERS[key].type === declared2.trigger.type)];
10263
+ if (declared2.workflow) {
10264
+ if (!declared2.workflow.key) {
10265
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no key \u2014 the title several steps share");
10266
+ }
10267
+ if (typeof declared2.workflow.order !== "number") {
10268
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no order \u2014 two steps in one row need a sequence");
10269
+ }
10270
+ if (declared2.trigger) {
10271
+ throw new Error(manifest.slug + " step " + type2 + " declares BOTH a trigger and a workflow \u2014 the workflow carries the trigger for every step in it");
10272
+ }
10273
+ }
10274
+ for (const candidate of [declared2.trigger, (_y = declared2.workflow) == null ? void 0 : _y.trigger].filter(Boolean)) {
10275
+ const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === candidate.event && TRIGGERS[key].type === candidate.type)];
7450
10276
  if (!known2) {
7451
10277
  throw new Error(
7452
- manifest.slug + " step " + type2 + " declares trigger { event : " + declared2.trigger.event + ", type : " + declared2.trigger.type + " }, which nothing dispatches"
10278
+ manifest.slug + " step " + type2 + " declares trigger { event : " + candidate.event + ", type : " + candidate.type + " }, which nothing dispatches"
7453
10279
  );
7454
10280
  }
7455
10281
  }