@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
@@ -42,6 +42,7 @@ __export(connections_exports, {
42
42
  HOOK_PROPS: () => HOOK_PROPS,
43
43
  HOOK_SLOT_PROPS: () => HOOK_SLOT_PROPS,
44
44
  INPUTS: () => INPUTS,
45
+ MARKERS: () => MARKERS,
45
46
  OAUTH_ENDPOINTS: () => OAUTH_ENDPOINTS,
46
47
  OAUTH_FIELDS: () => OAUTH_FIELDS,
47
48
  OUTCOMES: () => OUTCOMES,
@@ -62,28 +63,35 @@ __export(connections_exports, {
62
63
  buildVendor: () => buildVendor,
63
64
  catalogConnections: () => catalogConnections,
64
65
  connectFields: () => connectFields,
66
+ connectionSettings: () => connectionSettings,
65
67
  connectionSteps: () => connectionSteps,
66
68
  connections: () => connections,
67
69
  consentUrl: () => consentUrl,
68
70
  effectsOf: () => effectsOf,
69
71
  ensureSystemWorkflows: () => ensureSystemWorkflows,
70
72
  hookSupport: () => hookSupport,
73
+ implementsHook: () => implementsHook,
71
74
  isStale: () => isStale,
72
75
  isStatus: () => isStatus,
73
76
  mergeSettings: () => mergeSettings,
77
+ perform: () => perform,
74
78
  pkcePair: () => pkcePair,
75
79
  projectConnection: () => projectConnection,
76
80
  publicConnectionKeys: () => publicConnectionKeys,
77
81
  publicSettingsBySlug: () => publicSettingsBySlug,
82
+ reconcileConnectionScopes: () => reconcileConnectionScopes,
78
83
  redactSettings: () => redactSettings,
84
+ reference: () => reference,
79
85
  resolveConnection: () => resolveConnection,
80
86
  runHook: () => runHook,
81
87
  scopesMessage: () => scopesMessage,
88
+ spliced: () => spliced,
82
89
  stepLabels: () => stepLabels,
83
90
  stepQueues: () => stepQueues,
84
91
  stepRoutes: () => stepRoutes,
85
92
  surviving: () => surviving,
86
93
  systemSteps: () => systemSteps,
94
+ systemWorkflows: () => systemWorkflows,
87
95
  tokenSettings: () => tokenSettings,
88
96
  vendors: () => vendors
89
97
  });
@@ -215,6 +223,10 @@ var HOOKS = Object.freeze({
215
223
  "code",
216
224
  // Create the buyer at the vendor, so an order can be attributed to them.
217
225
  "customer",
226
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
227
+ // campaign's product list is rendered rather than stored, because stock is
228
+ // the one product fact that is stale the instant it is written down.
229
+ "inventory",
218
230
  // An order arrived at the vendor: attribute it, record it, update totals.
219
231
  "order",
220
232
  // Pull product data across on a vendor update.
@@ -282,7 +294,19 @@ var HOOKS = Object.freeze({
282
294
  organizations: Object.freeze(["add", "list", "remove"]),
283
295
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
284
296
  // declares the plan they are being charged on.
285
- plan: Object.freeze(["cancel", "reselect"]),
297
+ //
298
+ // `verify` is the odd one of the three: not something a merchant does, but
299
+ // the question "is this store's billing actually metered right now". It is
300
+ // here because it is the same subject — and because the answer was being
301
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
302
+ // approval webhook, whose copy said in its own comment that it matched the
303
+ // other and did not.
304
+ // `charge` and `verify` are the two that are not merchant actions: one
305
+ // sends a usage charge to the vendor's meter, the other asks whether that
306
+ // meter is actually working. Both are here because both are the same
307
+ // subject — what the merchant pays the vendor — and both were being done
308
+ // from drawbridge-sync with a direct import of the vendor's client.
309
+ plan: Object.freeze(["cancel", "charge", "reselect", "verify"]),
286
310
  // The embedded app's own session, stored by us on the vendor's behalf.
287
311
  session: Object.freeze(["clear", "read", "save"]),
288
312
  // The one-time code that hands a merchant from the embedded app into
@@ -379,7 +403,7 @@ var answerOf = (answer) => {
379
403
  return effectsOf(answer);
380
404
  };
381
405
  var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
382
- var INSTALL_SIGNATURE = Object.freeze({ options: ["read", "request"], props: ["context"] });
406
+ var INSTALL_SIGNATURE = Object.freeze({ options: ["read"], props: ["context"] });
383
407
  var LIFECYCLE_SIGNATURE = Object.freeze({ options: ["dispatch"], props: ["context"] });
384
408
  var RESOURCE_SIGNATURE = Object.freeze({
385
409
  options: ["fetcher", "shopify"],
@@ -396,15 +420,30 @@ var HOOK_SLOT_PROPS = Object.freeze({
396
420
  // the second bag would be silently discarded, which is exactly the
397
421
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
398
422
  "auth.token": false,
399
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
400
- // api/route/subdomain.js writes nothing, which is why it never became a hook
401
- // so its signature mirrors `code`, its nearest sibling, and moving the
402
- // route here is a deliberate widening of this line rather than a silent one.
403
- "commerce.cart": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
423
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
424
+ // not for an organization inside a workflow, so it takes no `connection` and
425
+ // no `step`. It reads the storefront with the store's own storefront token
426
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
427
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
428
+ // already carries them, because a credential is a fact of the run rather
429
+ // than a service.
430
+ "commerce.cart": Object.freeze({
431
+ options: ["fetcher", "shopify"],
432
+ props: ["clientId", "clientSecret", "context", "settings"]
433
+ }),
404
434
  "commerce.code": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context", "step"] }),
405
435
  "commerce.customer": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["connection", "context"] }),
436
+ "commerce.inventory": Object.freeze({ options: ["adminToken", "shopify"], props: ["connection", "context"] }),
406
437
  "commerce.order": Object.freeze({ options: ["conversionRate", "logger", "mintId", "read"], props: ["connection", "context"] }),
407
- "commerce.product": Object.freeze({ options: ["mintId", "read"], props: ["connection", "context", "workflow"] }),
438
+ // THREE OPERATIONS, one slot — record, sync, variants. `settings` and
439
+ // `adminToken` arrived with the pull half: the storefront read needs the
440
+ // store's storefront token and the product-feedback send needs its admin one,
441
+ // and both are resolved by the caller because minting either needs a
442
+ // controller.
443
+ "commerce.product": Object.freeze({
444
+ options: ["adminToken", "mintId", "read", "shopify"],
445
+ props: ["connection", "context", "settings", "workflow"]
446
+ }),
408
447
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
409
448
  // person, not the lead document. `remove` called it `email` and `sync` called
410
449
  // it `doc` on one vendor and `lead` on three — three names for two facts.
@@ -417,19 +456,58 @@ var HOOK_SLOT_PROPS = Object.freeze({
417
456
  "inbound.process": Object.freeze({ options: ["dispatch"], props: ["context"] }),
418
457
  "inbound.receive": Object.freeze({ options: [], props: ["channel", "event", "headers", "payload"] }),
419
458
  "inbound.verify": Object.freeze({ options: [], props: ["body", "channel", "headers", "secret", "url"] }),
420
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
421
- // routes in api/route/shopify-embedded.js become, and the signature is
422
- // deliberately narrow: `context` is the request's own body, `read` and
423
- // `request` are the only services an install surface has been shown to need.
424
- // A route that cannot be expressed inside this line is a finding to bring
425
- // back here, not a name to add at the call site.
459
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
460
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
461
+ // of them; the options are where they differ, and every difference below was
462
+ // forced by a line in those routes rather than chosen.
463
+ //
464
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
465
+ // token slots that write, the two clears, and both account slots answer from
466
+ // the shared install record alone.
467
+ //
468
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
469
+ // Drawbridge owns that the manifest must not:
470
+ //
471
+ // conversionRate, planTitle what Drawbridge charges this organization.
472
+ // The org list renders both, and importing the
473
+ // plan catalogue here is the cycle
474
+ // features -> connections -> shopify -> plans
475
+ // -> features that was just broken.
476
+ // currencies which settlement currencies Drawbridge can
477
+ // bill. A store outside them fails the
478
+ // connection validator INSIDE the transaction,
479
+ // which surfaces as an opaque 500, so `add`
480
+ // checks first and says so.
481
+ // mintId the connection's id, known before the write
482
+ // that creates it, because the organization's
483
+ // own refs have to name it in the same
484
+ // transaction.
485
+ // shopify the vendor. Four slots call the store: two
486
+ // read the live plan, two release it.
487
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
488
+ // `reselect` is the only slot with one. See its
489
+ // implementation for why the order is the point.
426
490
  "install.account.link": INSTALL_SIGNATURE,
427
491
  "install.account.unlink": INSTALL_SIGNATURE,
428
- "install.organizations.add": INSTALL_SIGNATURE,
429
- "install.organizations.list": INSTALL_SIGNATURE,
492
+ "install.organizations.add": Object.freeze({
493
+ options: ["currencies", "mintId", "read", "shopify"],
494
+ props: ["context"]
495
+ }),
496
+ "install.organizations.list": Object.freeze({
497
+ options: ["conversionRate", "planTitle", "read", "shopify"],
498
+ props: ["context"]
499
+ }),
430
500
  "install.organizations.remove": INSTALL_SIGNATURE,
431
- "install.plan.cancel": INSTALL_SIGNATURE,
432
- "install.plan.reselect": INSTALL_SIGNATURE,
501
+ "install.plan.cancel": Object.freeze({ options: ["read", "shopify"], props: ["context"] }),
502
+ // `manifest` because the handle this charge bills under is the vendor's own
503
+ // declaration (auth.install.billing.handle), and checking the job against the
504
+ // declaration is what the enqueuer-checks-itself version could not do.
505
+ "install.plan.charge": Object.freeze({
506
+ options: ["shopify"],
507
+ props: ["clientId", "clientSecret", "context", "manifest"]
508
+ }),
509
+ "install.plan.reselect": Object.freeze({ options: ["mark", "read", "shopify"], props: ["context"] }),
510
+ "install.plan.verify": Object.freeze({ options: ["adminToken", "read", "shopify"], props: ["context"] }),
433
511
  "install.session.clear": INSTALL_SIGNATURE,
434
512
  "install.session.read": INSTALL_SIGNATURE,
435
513
  "install.session.save": INSTALL_SIGNATURE,
@@ -516,6 +594,16 @@ var TRIGGERS = Object.freeze({
516
594
  "range.ended": Object.freeze({ event: "range.ended", type: "range" }),
517
595
  "range.started": Object.freeze({ event: "range.started", type: "range" }),
518
596
  "schedule.day": Object.freeze({ event: "day", type: "schedule" }),
597
+ // THE THREE SHOPIFY TRIGGERS, which were never in this vocabulary because the
598
+ // four workflows they fire were hand-written in drawbridge-sync's register job
599
+ // rather than provisioned from the manifest. `webhook` is a fourth trigger
600
+ // TYPE, and it is the honest name: these fire when the vendor calls us, which
601
+ // is neither an event Drawbridge raised nor a schedule it keeps.
602
+ "shopify.order.create": Object.freeze({ event: "shopify.order.create", type: "webhook" }),
603
+ "shopify.product.update": Object.freeze({ event: "shopify.product.update", type: "webhook" }),
604
+ // The token audit, dispatched programmatically by the register job when a
605
+ // store's grant is exchanged or rotated.
606
+ "shopify.token": Object.freeze({ event: "shopify.token", type: "event" }),
519
607
  "schedule.month": Object.freeze({ event: "month", type: "schedule" }),
520
608
  "schedule.week": Object.freeze({ event: "week", type: "schedule" }),
521
609
  "segment.contact.add": Object.freeze({ event: "segment.contact.add", type: "event" }),
@@ -568,7 +656,7 @@ var OUTCOMES = Object.freeze({
568
656
  unimplemented: "unimplemented",
569
657
  unsupported: "unsupported"
570
658
  });
571
- var STATUSES = Object.freeze(["active", "disconnected", "error", "pending"]);
659
+ var STATUSES = Object.freeze(["active", "error", "pending"]);
572
660
  var PLATFORM_CREDENTIALS = Object.freeze([
573
661
  // Seals a merchant's stored connection settings. The webhook connection is
574
662
  // gated on it because its whole credential is a secret WE mint and store, so
@@ -625,16 +713,188 @@ var ERROR_SOURCES = Object.freeze({
625
713
  "shopify.register.webhooks": true
626
714
  });
627
715
  var bearing = (entry) => ERROR_SOURCES[entry == null ? void 0 : entry.source] !== false;
628
- var assess = ({ errors = [], status = null } = {}) => {
629
- if (status === "disconnected") return status;
630
- return (errors || []).some(bearing) ? "error" : "active";
631
- };
716
+ var assess = ({ errors = [] } = {}) => (errors || []).some(bearing) ? "error" : "active";
632
717
  var surviving = ({ errors = [], proved = [] } = {}) => (errors || []).filter((entry) => !proved.includes(entry == null ? void 0 : entry.source));
633
718
  var isStatus = (status) => STATUSES.includes(status);
634
719
 
635
720
  // lib/usage.js
636
721
  var isDuplicateKey = (error) => (error == null ? void 0 : error.code) === 11e3 || (error == null ? void 0 : error.code) === "11000" || /E11000|duplicate key/i.test((error == null ? void 0 : error.message) || "");
637
722
 
723
+ // lib/connections/perform.js
724
+ var perform = async ({ answer, controller, emit, enqueue }) => {
725
+ const { enqueues, events, transaction, writes } = answerOf(answer) || {};
726
+ const write = async ({ collection, data, ignoreDuplicate, multiple, operation, options, query }, session) => {
727
+ try {
728
+ await controller[operation]({
729
+ collection,
730
+ data,
731
+ // The controller takes `multiple` beside options — an update that
732
+ // means every matching document must say so explicitly.
733
+ ...multiple && { multiple: true },
734
+ ...(options || session) && { options: { ...options, ...session && { session } } },
735
+ ...query && { query }
736
+ });
737
+ } catch (error) {
738
+ if (!(ignoreDuplicate && isDuplicateKey(error))) throw error;
739
+ }
740
+ };
741
+ if (transaction) {
742
+ await controller.transaction(async (session) => {
743
+ for (const descriptor of writes) await write(descriptor, session);
744
+ });
745
+ } else {
746
+ for (const descriptor of writes) await write(descriptor);
747
+ }
748
+ for (const { data, name, options, queue: target } of enqueues) {
749
+ if (!enqueue) throw new Error("A hook described an enqueue and this caller supplied no enqueuer");
750
+ await enqueue({ data, name, options, queue: target });
751
+ }
752
+ for (const descriptor of events) {
753
+ if (!emit) throw new Error("A hook described a socket event and this caller supplied no emitter");
754
+ emit(descriptor);
755
+ }
756
+ };
757
+
758
+ // lib/encrypt.js
759
+ var import_crypto2 = __toESM(require("crypto"), 1);
760
+
761
+ // lib/token.js
762
+ var import_crypto = __toESM(require("crypto"), 1);
763
+ var generate = (bytes = 32, encoding = "base64url") => {
764
+ const buf = import_crypto.default.randomBytes(bytes);
765
+ return encoding ? buf.toString(encoding) : buf;
766
+ };
767
+ var hash = (value, key) => {
768
+ if (!value) return null;
769
+ if (key) {
770
+ return import_crypto.default.createHmac("sha256", key).update(String(value)).digest("base64url");
771
+ }
772
+ return import_crypto.default.createHash("sha256").update(String(value)).digest("base64url");
773
+ };
774
+
775
+ // lib/encrypt.js
776
+ var ALGORITHM = "aes-256-gcm";
777
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
778
+ var encrypt = (value) => {
779
+ const iv = generate(12, null);
780
+ const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
781
+ const data = Buffer.concat([
782
+ cipher.update(JSON.stringify(value), "utf8"),
783
+ cipher.final()
784
+ ]);
785
+ const tag = cipher.getAuthTag();
786
+ return [iv, tag, data].map((b) => b.toString("hex")).join(":");
787
+ };
788
+ var decrypt = (value) => {
789
+ if (typeof value !== "string") return value;
790
+ const [ivHex, tagHex, dataHex] = value.split(":");
791
+ const decipher = import_crypto2.default.createDecipheriv(
792
+ ALGORITHM,
793
+ getKey(),
794
+ Buffer.from(ivHex, "hex")
795
+ );
796
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
797
+ const result = Buffer.concat([
798
+ decipher.update(Buffer.from(dataHex, "hex")),
799
+ decipher.final()
800
+ ]);
801
+ return JSON.parse(result.toString("utf8"));
802
+ };
803
+
804
+ // lib/connections/reconcile.js
805
+ var connectionSettings = async ({ controller, query }) => {
806
+ const connection = await controller.get({ collection: "connection", query });
807
+ if (!connection) return { connection: null, settings: null };
808
+ return { connection, settings: decrypt(connection.settings) };
809
+ };
810
+ var SCOPES_ERROR_SOURCE = "scopes";
811
+ var SCOPES_ERROR_MESSAGE = "Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.";
812
+ var reconcileConnectionScopes = async ({
813
+ controller,
814
+ enqueue,
815
+ granted = null,
816
+ logger,
817
+ shop,
818
+ slug: slug2 = "shopify"
819
+ }) => {
820
+ var _a, _b, _c;
821
+ if (granted === null) {
822
+ const record = await controller.get({ collection: "shop", query: { shop } });
823
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
824
+ granted = (session == null ? void 0 : session.scope) || null;
825
+ }
826
+ if (!granted) return null;
827
+ const answered = await runHook(slug2, "auth.scopes", { scope: granted });
828
+ const missing2 = answered.outcome === "answered" ? (_a = answered.answer.result) == null ? void 0 : _a.missing : null;
829
+ if (!Array.isArray(missing2)) {
830
+ (_b = logger == null ? void 0 : logger.warn) == null ? void 0 : _b.call(logger, "connection.scopes.unreadable", { shop, slug: slug2 });
831
+ return null;
832
+ }
833
+ const connections2 = await controller.aggregate({
834
+ collection: "connection",
835
+ pipeline: [{ $match: { shop, slug: slug2 } }]
836
+ });
837
+ let hadScopesError = false;
838
+ for (const connection of connections2) {
839
+ const others = (connection.errors || []).filter((entry) => entry.source !== SCOPES_ERROR_SOURCE);
840
+ const hasScopesError = others.length !== (connection.errors || []).length;
841
+ hadScopesError = hadScopesError || hasScopesError;
842
+ if (missing2.length) {
843
+ await controller.update({
844
+ collection: "connection",
845
+ data: {
846
+ $set: {
847
+ errors: [...others, { message: SCOPES_ERROR_MESSAGE, source: SCOPES_ERROR_SOURCE }],
848
+ status: "error"
849
+ }
850
+ },
851
+ query: { id: connection.id }
852
+ });
853
+ } else if (hasScopesError) {
854
+ await controller.update({
855
+ collection: "connection",
856
+ data: {
857
+ $set: {
858
+ errors: others,
859
+ ...connection.status === "error" && !others.length && { status: "active" }
860
+ }
861
+ },
862
+ query: { id: connection.id }
863
+ });
864
+ }
865
+ }
866
+ const products = connections2.length ? await controller.aggregate({
867
+ collection: "product",
868
+ pipeline: [{ $match: { "provider.slug": slug2, "source.domain": shop } }]
869
+ }) : [];
870
+ if (missing2.length && products.length) {
871
+ const productIds = products.map((product) => product.id);
872
+ await controller.update({
873
+ collection: "product",
874
+ data: { $set: { status: "inactive" } },
875
+ multiple: true,
876
+ query: { id: { $in: productIds } }
877
+ });
878
+ await controller.update({
879
+ collection: "advertisement",
880
+ data: { $set: { status: "drafted" } },
881
+ multiple: true,
882
+ query: { product: { $in: productIds } }
883
+ });
884
+ } else if (!missing2.length && hadScopesError && enqueue) {
885
+ for (const product of products) {
886
+ await enqueue({
887
+ data: { product: product.id },
888
+ name: "sync",
889
+ options: { jobId: "product." + slug2 + "." + product.id + "." + Date.now() },
890
+ queue: "product." + slug2
891
+ });
892
+ }
893
+ }
894
+ (_c = logger == null ? void 0 : logger.info) == null ? void 0 : _c.call(logger, "connection.scopes.reconciled", { connections: connections2.length, missing: missing2, shop });
895
+ return missing2;
896
+ };
897
+
638
898
  // lib/connections/workflows.js
639
899
  var systemSteps = (node, path = []) => Object.entries(node || {}).flatMap(
640
900
  ([key, value]) => {
@@ -645,16 +905,28 @@ var systemSteps = (node, path = []) => Object.entries(node || {}).flatMap(
645
905
  return value && typeof value === "object" ? systemSteps(value, [...path, key]) : [];
646
906
  }
647
907
  );
908
+ var systemWorkflows = (manifest) => {
909
+ var _a, _b, _c;
910
+ const grouped = /* @__PURE__ */ new Map();
911
+ for (const [type, declared] of systemSteps(manifest == null ? void 0 : manifest.steps)) {
912
+ const trigger = ((_a = declared.workflow) == null ? void 0 : _a.trigger) || declared.trigger;
913
+ if (!(trigger == null ? void 0 : trigger.event) || !(trigger == null ? void 0 : trigger.type)) continue;
914
+ const title = ((_b = declared.workflow) == null ? void 0 : _b.key) || declared.key;
915
+ if (!title) continue;
916
+ if (!grouped.has(title)) grouped.set(title, { steps: [], title, trigger });
917
+ grouped.get(title).steps.push({ order: ((_c = declared.workflow) == null ? void 0 : _c.order) ?? 0, type });
918
+ }
919
+ return [...grouped.values()].map((workflow) => ({
920
+ ...workflow,
921
+ steps: workflow.steps.slice().sort((a, b) => a.order - b.order)
922
+ }));
923
+ };
648
924
  var ensureSystemWorkflows = async ({ connections: registry = connections, controller, doc }) => {
649
- var _a, _b;
650
- if (!(doc == null ? void 0 : doc.id) || doc.slug === "shopify") return [];
925
+ if (!(doc == null ? void 0 : doc.id)) return [];
651
926
  const manifest = registry[doc.slug];
652
927
  if (!manifest) return [];
653
928
  const provisioned = [];
654
- for (const [type, declared] of systemSteps(manifest.steps)) {
655
- if (!((_a = declared.trigger) == null ? void 0 : _a.event) || !((_b = declared.trigger) == null ? void 0 : _b.type)) continue;
656
- const title = declared.key;
657
- if (!title) continue;
929
+ for (const { steps: members, title, trigger } of systemWorkflows(manifest)) {
658
930
  const existing = await controller.count({
659
931
  collection: "workflow",
660
932
  query: {
@@ -671,13 +943,10 @@ var ensureSystemWorkflows = async ({ connections: registry = connections, contro
671
943
  connection: doc.id,
672
944
  organization: doc.organization,
673
945
  status: "active",
674
- steps: [{
675
- settings: {},
676
- type
677
- }],
946
+ steps: members.map(({ type }) => ({ settings: {}, type })),
678
947
  system: true,
679
948
  title,
680
- trigger: declared.trigger
949
+ trigger
681
950
  };
682
951
  let created;
683
952
  try {
@@ -694,6 +963,135 @@ var ensureSystemWorkflows = async ({ connections: registry = connections, contro
694
963
  return provisioned;
695
964
  };
696
965
 
966
+ // lib/connections/reference.js
967
+ var MARKER = {
968
+ end: "<!-- /generated -->",
969
+ start: "<!-- generated: drawbridge-utils lib/connections/reference.js \u2014 do not edit by hand -->"
970
+ };
971
+ var slugs = () => Object.keys(connections).sort();
972
+ var slots = (node = HOOKS, path = []) => Object.entries(node).flatMap(
973
+ ([key, value]) => Array.isArray(value) ? value.map((verb) => [...path, key, verb].join(".")) : slots(value, [...path, key])
974
+ );
975
+ var implemented = (hooks, name) => {
976
+ const found = name.split(".").reduce(
977
+ (node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
978
+ hooks
979
+ );
980
+ if (typeof found === "function") return "yes";
981
+ if (found && typeof found === "object") return "elsewhere";
982
+ return "no";
983
+ };
984
+ var table = (header, rows) => [
985
+ "| " + header.join(" | ") + " |",
986
+ "|" + header.map(() => "---").join("|") + "|",
987
+ ...rows.map((row2) => "| " + row2.join(" | ") + " |")
988
+ ].join("\n");
989
+ var matrix = () => {
990
+ const columns = slugs();
991
+ return table(
992
+ ["Hook", ...columns],
993
+ slots().map((name) => [
994
+ "`" + name + "`",
995
+ ...columns.map((slug2) => implemented(connections[slug2].hooks, name))
996
+ ])
997
+ );
998
+ };
999
+ var vocabularies = () => {
1000
+ const list2 = (values) => values.map((value) => "`" + value + "`").join(", ");
1001
+ return table(
1002
+ ["Vocabulary", "Values"],
1003
+ [
1004
+ ["`AUTH_KINDS`", list2(AUTH_KINDS)],
1005
+ ["`GROUPS`", list2(GROUPS)],
1006
+ ["`INPUTS`", list2(INPUTS)],
1007
+ ["`STATUSES`", list2(STATUSES)],
1008
+ ["`TRIGGERS`", list2(Object.keys(TRIGGERS).sort())],
1009
+ ["`TRIGGER_TYPES`", list2(TRIGGER_TYPES)]
1010
+ ]
1011
+ );
1012
+ };
1013
+ var steps = () => {
1014
+ const rows = [];
1015
+ const walk = (node, path, slug2) => {
1016
+ var _a;
1017
+ if (!node || typeof node !== "object") return;
1018
+ for (const [key, value] of Object.entries(node)) {
1019
+ if (typeof value === "function") {
1020
+ let declared;
1021
+ try {
1022
+ declared = value({});
1023
+ } catch {
1024
+ continue;
1025
+ }
1026
+ if (!(declared == null ? void 0 : declared.type)) continue;
1027
+ const fires = ((_a = declared.workflow) == null ? void 0 : _a.trigger) || declared.trigger;
1028
+ rows.push([
1029
+ "`" + declared.type + "`",
1030
+ slug2,
1031
+ declared.hook ? "`" + declared.hook + "`" : "\u2014",
1032
+ declared.queue,
1033
+ declared.system ? "system" : declared.withdrawn ? "withdrawn" : "builder",
1034
+ fires ? "`" + fires.event + "` / `" + fires.type + "`" : (declared.triggers || []).map((t) => "`" + t + "`").join(", ") || "\u2014"
1035
+ ]);
1036
+ continue;
1037
+ }
1038
+ walk(value, [...path, key], slug2);
1039
+ }
1040
+ };
1041
+ for (const slug2 of slugs()) walk(connections[slug2].steps, [], slug2);
1042
+ return table(["Step", "Connection", "Hook", "Queue", "Shape", "Fired by"], rows.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])));
1043
+ };
1044
+ var workflows = () => table(
1045
+ ["Workflow", "Connection", "Steps", "Trigger"],
1046
+ slugs().flatMap((slug2) => systemWorkflows(connections[slug2]).map((workflow) => [
1047
+ workflow.title,
1048
+ slug2,
1049
+ workflow.steps.map((step) => "`" + step.type + "`").join(" \u2192 "),
1050
+ "`" + workflow.trigger.event + "` / `" + workflow.trigger.type + "`"
1051
+ ]))
1052
+ );
1053
+ var reference = () => [
1054
+ MARKER.start,
1055
+ "",
1056
+ "### The support matrix",
1057
+ "",
1058
+ "`yes` is a body in this package. `elsewhere` is declared here and implemented in the repo holding the dependencies \u2014 a different fact from `no`, and collapsing the two is how a table tells a reader the package can do something it cannot.",
1059
+ "",
1060
+ matrix(),
1061
+ "",
1062
+ "### The closed vocabularies",
1063
+ "",
1064
+ vocabularies(),
1065
+ "",
1066
+ "### Every step",
1067
+ "",
1068
+ "A step with no hook is real: Shopify's token-audit pair is declared so a stray dispatch lands on a real queue, and is never fired.",
1069
+ "",
1070
+ steps(),
1071
+ "",
1072
+ "### The system workflows a connection is provisioned with",
1073
+ "",
1074
+ "Idempotent on (connection, system, title), so the TITLE is load-bearing \u2014 one that varied by account would provision a second workflow every time it changed.",
1075
+ "",
1076
+ workflows(),
1077
+ "",
1078
+ "### Counts",
1079
+ "",
1080
+ "- **" + slugs().length + "** connections",
1081
+ "- **" + slots().length + "** hook slots",
1082
+ "- **" + Object.keys(STEPS).length + "** step types",
1083
+ "- **" + Object.keys(TRIGGERS).length + "** triggers",
1084
+ "",
1085
+ MARKER.end
1086
+ ].join("\n");
1087
+ var spliced = (document) => {
1088
+ const from = document.indexOf(MARKER.start);
1089
+ const to = document.indexOf(MARKER.end);
1090
+ if (from < 0 || to < 0) return document;
1091
+ return document.slice(0, from) + reference() + document.slice(to + MARKER.end.length);
1092
+ };
1093
+ var MARKERS = MARKER;
1094
+
697
1095
  // lib/connections/oauth.js
698
1096
  var import_node_crypto = require("crypto");
699
1097
  var credentials = ({ basic, clientId, clientSecret }) => basic ? {
@@ -748,10 +1146,10 @@ var authToken = async ({
748
1146
  const renewing = !code;
749
1147
  if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
750
1148
  if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
751
- const client = credentials({ basic, clientId, clientSecret });
1149
+ const client2 = credentials({ basic, clientId, clientSecret });
752
1150
  const response = await fetcher(descriptor.endpoints.token, {
753
1151
  body: new URLSearchParams({
754
- ...client.body,
1152
+ ...client2.body,
755
1153
  ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
756
1154
  code: decodeURIComponent(String(code || "").trim()),
757
1155
  grant_type: "authorization_code",
@@ -759,7 +1157,7 @@ var authToken = async ({
759
1157
  ...descriptor.pkce && { code_verifier: verifier }
760
1158
  }
761
1159
  }),
762
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
1160
+ headers: { "content-type": "application/x-www-form-urlencoded", ...client2.headers },
763
1161
  method: "POST",
764
1162
  signal: AbortSignal.timeout(15e3)
765
1163
  });
@@ -1215,9 +1613,18 @@ var scaffold_default = {
1215
1613
  commerce: {
1216
1614
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
1217
1615
  // the last api surface Shopify owns outside a hook.
1218
- cart: async ({ connection, context }, { read } = {}) => ANSWER,
1616
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
1617
+ // page, not for an organization inside a workflow. It reads the storefront
1618
+ // with the store's storefront token and mints a checkout token from
1619
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
1620
+ // as props — the way auth.disconnect already carries it.
1621
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify } = {}) => ANSWER,
1219
1622
  code: async ({ connection, context, step }, { adminToken, shopify } = {}) => ANSWER,
1220
1623
  customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => ANSWER,
1624
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
1625
+ // fact that is stale the instant it is written down, so the campaign
1626
+ // product list asks the vendor and caches the answer for a minute.
1627
+ inventory: async ({ connection, context }, { adminToken, shopify } = {}) => ANSWER,
1221
1628
  order: async ({ connection, context }, { logger, mintId, read } = {}) => ANSWER,
1222
1629
  // THIS IS WHERE `product.insert` LANDS.
1223
1630
  //
@@ -1239,7 +1646,7 @@ var scaffold_default = {
1239
1646
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
1240
1647
  // a log. Phase 1 closes it and build() checks every trigger against it.
1241
1648
  product: {
1242
- hook: async ({ connection, context, workflow }, { mintId, read } = {}) => ANSWER,
1649
+ hook: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify } = {}) => ANSWER,
1243
1650
  step: () => ({
1244
1651
  description: "Syncs product data from the vendor when a product changes.",
1245
1652
  key: "Vendor Product Sync",
@@ -1387,8 +1794,19 @@ var scaffold_default = {
1387
1794
  // Vendor billing, the only group that is not generic. Pairs with
1388
1795
  // auth.install.billing, which declares what the merchant is being charged.
1389
1796
  plan: {
1390
- cancel: async ({ context }, { dispatch, read } = {}) => ANSWER,
1391
- reselect: async ({ context }, { dispatch, read } = {}) => ANSWER
1797
+ cancel: async ({ context }, { read, shopify } = {}) => ANSWER,
1798
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
1799
+ // sent to the vendor's meter. The credential pair rides in as props
1800
+ // because the charge authenticates as the APP, not as the store.
1801
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify } = {}) => ANSWER,
1802
+ // `mark` is the one write a description cannot carry: the marker must
1803
+ // be persisted BEFORE the approval is released, and a described write
1804
+ // lands after the hook returns.
1805
+ reselect: async ({ context }, { mark, read, shopify } = {}) => ANSWER,
1806
+ // NOT A MERCHANT ACTION — the question "is this store's billing
1807
+ // actually metered right now", asked by the daily health check and by
1808
+ // the vendor's own approval webhook, which used to hand-copy it.
1809
+ verify: async ({ context }, { adminToken, read, shopify } = {}) => ANSWER
1392
1810
  },
1393
1811
  // The embedded app's own session, stored by us on the vendor's behalf.
1394
1812
  session: {
@@ -1874,16 +2292,19 @@ var attentive_default2 = {
1874
2292
  "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.",
1875
2293
  "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."
1876
2294
  ],
1877
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
1878
- // The dashboard already carries generic reasons for every way an
1879
- // authorization can fail — declined, expired state, a vendor we are not set
1880
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
1881
- // failures need saying differently. This one's do not.
2295
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
2296
+ // consent screen has two ways to end: they said no, or something between
2297
+ // here and the vendor went wrong.
1882
2298
  //
1883
- // Answered rather than omitted, because `errors` existed on two of six
1884
- // manifests and there was no way to tell "nothing to add" from "nobody
1885
- // wrote it".
1886
- errors: false,
2299
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
2300
+ // authorization"; a merchant connecting two accounts in one sitting needs
2301
+ // to know which one this was.
2302
+ errors: {
2303
+ connect: {
2304
+ denied: "The Attentive authorization was declined, so nothing was connected.",
2305
+ invalid: "We couldn't complete the Attentive connection. Try connecting again."
2306
+ }
2307
+ },
1887
2308
  excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
1888
2309
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
1889
2310
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -2411,6 +2832,15 @@ var readEventHeader = ({ descriptor, headers }) => ({ result: { event: headers[d
2411
2832
  var DEFAULT_TIMEOUT_MS = 15e3;
2412
2833
  var request = async ({
2413
2834
  body,
2835
+ // THE TRANSPORT, injectable and defaulted to the real one.
2836
+ //
2837
+ // Every other vendor client in this package takes a `fetcher` — it is how
2838
+ // klaviyo, mailchimp and attentive are tested without a network, and it is a
2839
+ // declared HOOK_OPTION. The Shopify client had no seam at all, so the only way
2840
+ // to test a hook that used it was to inject the whole SDK namespace; that
2841
+ // injection existed to work around an import cycle, and when the cycle went
2842
+ // the tests lost their only hold. This is the seam they should have had.
2843
+ fetcher = fetch,
2414
2844
  headers = {},
2415
2845
  method = "GET",
2416
2846
  query,
@@ -2424,7 +2854,7 @@ var request = async ({
2424
2854
  }
2425
2855
  ;
2426
2856
  const isForm = type === "form";
2427
- const response = await fetch(fullUrl.toString(), {
2857
+ const response = await fetcher(fullUrl.toString(), {
2428
2858
  method,
2429
2859
  headers: {
2430
2860
  "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
@@ -2881,15 +3311,12 @@ var drawbridge_default2 = {
2881
3311
  description: [
2882
3312
  "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
2883
3313
  ],
2884
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2885
- // The dashboard already carries generic reasons for every way an
2886
- // authorization can fail declined, expired state, a vendor we are not set
2887
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2888
- // failures need saying differently. This one's do not.
3314
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
3315
+ // own manifest always on, private, never in the catalogue — and it exists
3316
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
3317
+ // describe a consent screen this connection does not have.
2889
3318
  //
2890
- // Answered rather than omitted, because `errors` existed on two of six
2891
- // manifests and there was no way to tell "nothing to add" from "nobody
2892
- // wrote it".
3319
+ // `false` is the recorded decision, not silence.
2893
3320
  errors: false,
2894
3321
  excerpt: "The steps Drawbridge runs itself.",
2895
3322
  guide: [
@@ -3649,10 +4076,10 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3649
4076
 
3650
4077
  // lib/connections/scopes.js
3651
4078
  var list = (value) => (Array.isArray(value) ? value.flatMap((entry) => String(entry).split(/\s+/)) : String(value ?? "").split(/\s+/)).map((entry) => entry.trim()).filter(Boolean);
3652
- var missingScopes = ({ granted, required }) => {
4079
+ var missingScopes = ({ granted, required: required2 }) => {
3653
4080
  if (granted === null || granted === void 0 || granted === "") return null;
3654
4081
  const held = new Set(list(granted));
3655
- return list(required).filter((scope) => !held.has(scope));
4082
+ return list(required2).filter((scope) => !held.has(scope));
3656
4083
  };
3657
4084
 
3658
4085
  // lib/connections/manifests/klaviyo.js
@@ -4551,16 +4978,19 @@ var mailchimp_default2 = {
4551
4978
  "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.",
4552
4979
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
4553
4980
  ],
4554
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4555
- // The dashboard already carries generic reasons for every way an
4556
- // authorization can fail — declined, expired state, a vendor we are not set
4557
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4558
- // failures need saying differently. This one's do not.
4981
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
4982
+ // consent screen has two ways to end: they said no, or something between
4983
+ // here and the vendor went wrong.
4559
4984
  //
4560
- // Answered rather than omitted, because `errors` existed on two of six
4561
- // manifests and there was no way to tell "nothing to add" from "nobody
4562
- // wrote it".
4563
- errors: false,
4985
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
4986
+ // authorization"; a merchant connecting two accounts in one sitting needs
4987
+ // to know which one this was.
4988
+ errors: {
4989
+ connect: {
4990
+ denied: "The Mailchimp authorization was declined, so nothing was connected.",
4991
+ invalid: "We couldn't complete the Mailchimp connection. Try connecting again."
4992
+ }
4993
+ },
4564
4994
  excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
4565
4995
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
4566
4996
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -4659,7 +5089,7 @@ var mailchimp_default2 = {
4659
5089
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4660
5090
  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);
4661
5091
  if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4662
- const hash = subscriberHash(email);
5092
+ const hash2 = subscriberHash(email);
4663
5093
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4664
5094
  const lastName = restOfName.join(" ");
4665
5095
  const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
@@ -4668,7 +5098,7 @@ var mailchimp_default2 = {
4668
5098
  ...lastName && { LNAME: lastName },
4669
5099
  ...phone && { PHONE: phone }
4670
5100
  };
4671
- const member = await api3("/lists/" + audience + "/members/" + hash, {
5101
+ const member = await api3("/lists/" + audience + "/members/" + hash2, {
4672
5102
  dc: settings == null ? void 0 : settings.dc,
4673
5103
  fetcher,
4674
5104
  method: "PUT",
@@ -4709,7 +5139,7 @@ var mailchimp_default2 = {
4709
5139
  status: joined.has(entry.id) ? "active" : "inactive"
4710
5140
  }));
4711
5141
  if (tags.length > 0) {
4712
- await api3("/lists/" + audience + "/members/" + hash + "/tags", {
5142
+ await api3("/lists/" + audience + "/members/" + hash2 + "/tags", {
4713
5143
  dc: settings == null ? void 0 : settings.dc,
4714
5144
  fetcher,
4715
5145
  method: "POST",
@@ -4720,12 +5150,12 @@ var mailchimp_default2 = {
4720
5150
  }
4721
5151
  return {
4722
5152
  // Merged into `context` for later steps in this run.
4723
- context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
5153
+ context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 },
4724
5154
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4725
5155
  request: request2,
4726
5156
  // Recorded on the run for support to read back, not a write
4727
5157
  // instruction — the hook has already written what it needed to.
4728
- response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
5158
+ response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash2 }
4729
5159
  };
4730
5160
  }
4731
5161
  },
@@ -5095,139 +5525,2030 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
5095
5525
  <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"/>
5096
5526
  </svg>`;
5097
5527
 
5098
- // lib/email.js
5099
- var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
5100
- var toCanonicalEmail = (value) => {
5101
- if (!value || typeof value !== "string") return null;
5102
- const email = value.trim().toLowerCase();
5103
- const at = email.lastIndexOf("@");
5104
- if (at < 1 || at === email.length - 1) return null;
5105
- let local = email.slice(0, at);
5106
- const domain = email.slice(at + 1);
5107
- const plus = local.indexOf("+");
5108
- if (plus > 0) local = local.slice(0, plus);
5109
- if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
5110
- if (!local) return null;
5111
- return local + "@" + domain;
5112
- };
5528
+ // lib/shopify/admin.js
5529
+ var admin_exports = {};
5530
+ __export(admin_exports, {
5531
+ adminFetch: () => adminFetch,
5532
+ createDiscountCode: () => createDiscountCode,
5533
+ createUsageRecord: () => createUsageRecord,
5534
+ createUsageSubscription: () => createUsageSubscription,
5535
+ getActiveAppSubscriptions: () => getActiveAppSubscriptions,
5536
+ getDiscounts: () => getDiscounts,
5537
+ getOrCreateCustomer: () => getOrCreateCustomer,
5538
+ getProductInventory: () => getProductInventory,
5539
+ sendProductResourceFeedback: () => sendProductResourceFeedback
5540
+ });
5113
5541
 
5114
- // lib/encrypt.js
5115
- var import_crypto = __toESM(require("crypto"), 1);
5116
- var ALGORITHM = "aes-256-gcm";
5117
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
5118
- var decrypt = (value) => {
5119
- if (typeof value !== "string") return value;
5120
- const [ivHex, tagHex, dataHex] = value.split(":");
5121
- const decipher = import_crypto.default.createDecipheriv(
5122
- ALGORITHM,
5123
- getKey(),
5124
- Buffer.from(ivHex, "hex")
5125
- );
5126
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
5127
- const result = Buffer.concat([
5128
- decipher.update(Buffer.from(dataHex, "hex")),
5129
- decipher.final()
5130
- ]);
5131
- return JSON.parse(result.toString("utf8"));
5132
- };
5542
+ // lib/shopify/constants.js
5543
+ var SHOPIFY_STOREFRONT_API_VERSION = "2025-01";
5544
+ var SHOPIFY_ADMIN_API_VERSION = "2026-04";
5545
+ var SHOPIFY_APP_API_VERSION = "unstable";
5546
+ var SHOPIFY_REQUIRED_SCOPES = [
5547
+ "read_customers",
5548
+ "write_customers",
5549
+ "read_discounts",
5550
+ "write_discounts",
5551
+ "read_orders",
5552
+ "read_products",
5553
+ "read_publications",
5554
+ "write_resource_feedbacks",
5555
+ "unauthenticated_read_checkouts",
5556
+ "unauthenticated_read_product_inventory",
5557
+ "unauthenticated_read_product_listings",
5558
+ "unauthenticated_write_checkouts"
5559
+ ];
5560
+ var REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1e3;
5561
+ var ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
5562
+ var PARTNER_API_VERSION = process.env.SHOPIFY_PARTNER_API_VERSION || "2026-07";
5133
5563
 
5134
- // lib/slugify.js
5135
- var import_slugify = __toESM(require("slugify"), 1);
5136
- var slugify = (value, nochars = false) => {
5137
- const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
5138
- const replacement = nochars ? "" : "-";
5139
- return (0, import_slugify.default)(
5140
- value,
5141
- {
5142
- remove,
5143
- replacement,
5144
- lower: true,
5145
- trim: true
5564
+ // lib/shopify/admin.js
5565
+ var adminUrl = (domain) => `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}`;
5566
+ var adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
5567
+ var _a;
5568
+ const { data, errors } = await request({
5569
+ fetcher,
5570
+ method: "POST",
5571
+ url: adminUrl(domain) + "/graphql.json",
5572
+ headers: {
5573
+ "X-Shopify-Access-Token": adminAccessToken
5574
+ },
5575
+ body: {
5576
+ query,
5577
+ ...variables && { variables }
5146
5578
  }
5147
- );
5579
+ });
5580
+ if (errors) {
5581
+ throw new Error("Shopify GraphQL error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
5582
+ }
5583
+ ;
5584
+ return data;
5148
5585
  };
5149
-
5150
- // lib/connections/manifests/shopify.js
5151
- var toLine = ({
5152
- price,
5153
- product_id: productId,
5154
- quantity,
5155
- title,
5156
- variant_id: variantId,
5157
- variant_title: variantTitle
5158
- }) => ({
5159
- price: parseFloat(price) || 0,
5160
- productId: productId ? "gid://shopify/Product/" + productId : null,
5161
- quantity: quantity || 1,
5162
- title: title || null,
5163
- variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
5164
- variantTitle: variantTitle || null
5165
- });
5166
- var attributeLineItems = (lineItems = []) => lineItems.reduce(
5167
- (acc, item) => {
5168
- const attrs = (item.properties || []).reduce(
5169
- (map, { name, value }) => {
5170
- map[name] = value;
5171
- return map;
5172
- },
5173
- {}
5174
- );
5175
- if (!attrs["_drwbrdg_ca"]) return acc;
5176
- if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
5177
- const line = toLine(item);
5178
- acc.attributedGross += line.price * line.quantity;
5179
- acc.attributedLines.push(line);
5180
- return acc;
5181
- },
5182
- { attrMap: {}, attributedGross: 0, attributedLines: [] }
5183
- );
5184
- var generateDiscountCode = (0, import_nanoid.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
5185
- var blockedReason = (discount) => {
5186
- var _a;
5187
- if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
5188
- const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
5189
- if (buyers && buyers !== "DiscountBuyerSelectionAll") {
5190
- 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.";
5586
+ var DISCOUNT_FIELDS = `
5587
+ title
5588
+ status
5589
+ startsAt
5590
+ endsAt
5591
+ usageLimit
5592
+ asyncUsageCount
5593
+ appliesOncePerCustomer
5594
+ context { __typename }
5595
+ `;
5596
+ var discountsQuery = (search, cursor, limit) => `{
5597
+ codeDiscountNodes(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}) {
5598
+ edges {
5599
+ node {
5600
+ id
5601
+ codeDiscount {
5602
+ ... on DiscountCodeBasic { ${DISCOUNT_FIELDS} }
5603
+ ... on DiscountCodeBxgy { ${DISCOUNT_FIELDS} }
5604
+ ... on DiscountCodeFreeShipping { ${DISCOUNT_FIELDS} }
5605
+ ... on DiscountCodeApp { ${DISCOUNT_FIELDS} }
5606
+ }
5607
+ }
5608
+ }
5609
+ pageInfo {
5610
+ endCursor
5611
+ hasNextPage
5612
+ hasPreviousPage
5613
+ startCursor
5614
+ }
5615
+ }
5616
+ }`;
5617
+ var getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
5618
+ const data = await adminFetch({
5619
+ fetcher,
5620
+ adminAccessToken,
5621
+ domain,
5622
+ query: discountsQuery(search, cursor, limit)
5623
+ });
5624
+ return data == null ? void 0 : data.codeDiscountNodes;
5625
+ };
5626
+ var discountGid = (id) => String(id).startsWith("gid://") ? String(id) : "gid://shopify/DiscountCodeNode/" + id;
5627
+ var createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code }) => {
5628
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5629
+ const added = await adminFetch({
5630
+ fetcher,
5631
+ adminAccessToken,
5632
+ domain,
5633
+ query: `
5634
+ mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
5635
+ discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
5636
+ bulkCreation {
5637
+ id
5638
+ }
5639
+ userErrors {
5640
+ field
5641
+ message
5642
+ }
5643
+ }
5644
+ }
5645
+ `,
5646
+ variables: {
5647
+ codes: [{ code }],
5648
+ discountId: discountGid(discountId)
5649
+ }
5650
+ });
5651
+ const addErrors = (_a = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _a.userErrors;
5652
+ if (addErrors == null ? void 0 : addErrors.length) {
5653
+ throw new Error("Shopify discount code create failed: " + addErrors[0].message);
5191
5654
  }
5192
5655
  ;
5193
- if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
5194
- return "This discount has reached its total usage limit.";
5656
+ const bulkCreationId = (_c = (_b = added == null ? void 0 : added.discountRedeemCodeBulkAdd) == null ? void 0 : _b.bulkCreation) == null ? void 0 : _c.id;
5657
+ if (!bulkCreationId) {
5658
+ throw new Error("Shopify discount code create failed: no bulk creation id returned");
5195
5659
  }
5196
5660
  ;
5197
- return null;
5198
- };
5199
- var discountWarning = (discount) => {
5200
- if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
5201
- return "This discount hasn't started yet, so codes issued before it does won't work until then.";
5661
+ for (let attempt = 0; attempt < 15; attempt++) {
5662
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5663
+ const polled = await adminFetch({
5664
+ fetcher,
5665
+ adminAccessToken,
5666
+ domain,
5667
+ query: `
5668
+ query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
5669
+ discountRedeemCodeBulkCreation(id: $id) {
5670
+ done
5671
+ codes(first: 1) {
5672
+ nodes {
5673
+ code
5674
+ discountRedeemCode {
5675
+ id
5676
+ }
5677
+ errors {
5678
+ message
5679
+ }
5680
+ }
5681
+ }
5682
+ }
5683
+ }
5684
+ `,
5685
+ variables: {
5686
+ id: bulkCreationId
5687
+ }
5688
+ });
5689
+ const creation = polled == null ? void 0 : polled.discountRedeemCodeBulkCreation;
5690
+ if (!(creation == null ? void 0 : creation.done)) continue;
5691
+ const node = (_e = (_d = creation.codes) == null ? void 0 : _d.nodes) == null ? void 0 : _e[0];
5692
+ if (!((_f = node == null ? void 0 : node.discountRedeemCode) == null ? void 0 : _f.id)) {
5693
+ 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"));
5694
+ }
5695
+ ;
5696
+ return {
5697
+ code: node.code,
5698
+ // Numeric tail keeps the stored shopifyDiscountId in the shape the
5699
+ // legacy REST endpoint returned — it feeds a merchant-visible
5700
+ // workflow template variable, so no gid:// leakage.
5701
+ id: node.discountRedeemCode.id.split("/").pop()
5702
+ };
5202
5703
  }
5203
5704
  ;
5204
- if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
5205
- return null;
5705
+ throw new Error("Shopify discount code create timed out waiting for bulk creation " + bulkCreationId);
5206
5706
  };
5207
- var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
5208
- var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
5209
- var OAUTH_ERROR_SOURCE = "oauth";
5210
- var BILLING_ERROR_SOURCE = "billing";
5211
- var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
5212
- var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
5213
- var inbound = {
5214
- headers: {
5215
- event: "x-shopify-topic",
5216
- id: "x-shopify-webhook-id",
5217
- shop: "x-shopify-shop-domain",
5218
- signature: "x-shopify-hmac-sha256"
5219
- },
5220
- signature: {
5221
- algorithm: "sha256",
5222
- encoding: "base64",
5223
- secret: "SHOPIFY_API_SECRET"
5707
+ var getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
5708
+ var _a, _b, _c, _d, _e, _f, _g, _h;
5709
+ const lookupQuery = `
5710
+ query customerByEmail( $query : String! ) {
5711
+ customers( first : 1, query : $query ) {
5712
+ edges {
5713
+ node {
5714
+ id
5715
+ }
5716
+ }
5717
+ }
5718
+ }
5719
+ `;
5720
+ const data = await adminFetch({
5721
+ fetcher,
5722
+ adminAccessToken,
5723
+ domain,
5724
+ query: lookupQuery,
5725
+ variables: { query: "email:" + email }
5726
+ });
5727
+ 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;
5728
+ if (existing) return existing;
5729
+ const input = { email };
5730
+ if (firstName) input.firstName = firstName;
5731
+ if (lastName) input.lastName = lastName;
5732
+ if (source) {
5733
+ input.metafields = [{
5734
+ namespace: "drwbrdg",
5735
+ key: "source",
5736
+ type: "single_line_text_field",
5737
+ value: source
5738
+ }];
5224
5739
  }
5225
- };
5226
- var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
5227
- "customers/data_request",
5228
- "customers/redact",
5740
+ ;
5741
+ const result = await adminFetch({
5742
+ fetcher,
5743
+ adminAccessToken,
5744
+ domain,
5745
+ query: `
5746
+ mutation customerCreate( $input : CustomerInput! ) {
5747
+ customerCreate( input : $input ) {
5748
+ customer {
5749
+ id
5750
+ }
5751
+ userErrors {
5752
+ field
5753
+ message
5754
+ }
5755
+ }
5756
+ }
5757
+ `,
5758
+ variables: { input }
5759
+ });
5760
+ const userErrors = (_d = result == null ? void 0 : result.customerCreate) == null ? void 0 : _d.userErrors;
5761
+ if (userErrors == null ? void 0 : userErrors.length) {
5762
+ const emailTaken = userErrors.some((e) => {
5763
+ var _a2;
5764
+ return (_a2 = e.message) == null ? void 0 : _a2.includes("Email has already been taken");
5765
+ });
5766
+ if (emailTaken) {
5767
+ const retry = await adminFetch({
5768
+ fetcher,
5769
+ adminAccessToken,
5770
+ domain,
5771
+ query: lookupQuery,
5772
+ variables: { query: "email:" + email }
5773
+ });
5774
+ 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;
5775
+ if (found) return found;
5776
+ }
5777
+ ;
5778
+ throw new Error("Shopify customer create failed: " + userErrors[0].message);
5779
+ }
5780
+ ;
5781
+ return (_h = result == null ? void 0 : result.customerCreate) == null ? void 0 : _h.customer;
5782
+ };
5783
+ var getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
5784
+ var _a, _b;
5785
+ const data = await adminFetch({
5786
+ fetcher,
5787
+ adminAccessToken,
5788
+ domain,
5789
+ query: `{
5790
+ currentAppInstallation {
5791
+ activeSubscriptions {
5792
+ id
5793
+ name
5794
+ status
5795
+ test
5796
+ lineItems {
5797
+ id
5798
+ plan {
5799
+ pricingDetails {
5800
+ __typename
5801
+ }
5802
+ }
5803
+ }
5804
+ }
5805
+ }
5806
+ shop {
5807
+ myshopifyDomain
5808
+ }
5809
+ }`
5810
+ });
5811
+ return {
5812
+ shopDomain: ((_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.myshopifyDomain) || null,
5813
+ subscriptions: (((_b = data == null ? void 0 : data.currentAppInstallation) == null ? void 0 : _b.activeSubscriptions) || []).map((subscription) => {
5814
+ var _a2;
5815
+ return {
5816
+ ...subscription,
5817
+ metered: ((subscription == null ? void 0 : subscription.lineItems) || []).some(
5818
+ (item) => {
5819
+ var _a3, _b2;
5820
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5821
+ }
5822
+ ),
5823
+ // The usage line's own id — the address appUsageRecordCreate charges
5824
+ // against. Null when the approval carries no usage component.
5825
+ usageLineItemId: ((_a2 = ((subscription == null ? void 0 : subscription.lineItems) || []).find(
5826
+ (item) => {
5827
+ var _a3, _b2;
5828
+ return ((_b2 = (_a3 = item == null ? void 0 : item.plan) == null ? void 0 : _a3.pricingDetails) == null ? void 0 : _b2.__typename) === "AppUsagePricing";
5829
+ }
5830
+ )) == null ? void 0 : _a2.id) || null
5831
+ };
5832
+ })
5833
+ };
5834
+ };
5835
+ var createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = "USD", domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
5836
+ if (!name || !returnUrl || !terms || !(Number(cappedAmount) > 0)) {
5837
+ throw new Error("createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount");
5838
+ }
5839
+ ;
5840
+ const data = await adminFetch({
5841
+ fetcher,
5842
+ adminAccessToken,
5843
+ domain,
5844
+ query: `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
5845
+ appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
5846
+ appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
5847
+ confirmationUrl
5848
+ userErrors { field message }
5849
+ }
5850
+ }`,
5851
+ variables: {
5852
+ lineItems: [
5853
+ {
5854
+ plan: {
5855
+ appRecurringPricingDetails: {
5856
+ interval: "EVERY_30_DAYS",
5857
+ price: { amount: Number(recurringPrice), currencyCode }
5858
+ }
5859
+ }
5860
+ },
5861
+ {
5862
+ plan: {
5863
+ appUsagePricingDetails: {
5864
+ cappedAmount: { amount: Number(cappedAmount), currencyCode },
5865
+ terms
5866
+ }
5867
+ }
5868
+ }
5869
+ ],
5870
+ name,
5871
+ returnUrl,
5872
+ test
5873
+ }
5874
+ });
5875
+ const payload = data == null ? void 0 : data.appSubscriptionCreate;
5876
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5877
+ if (userErrors.length) {
5878
+ throw new Error("Shopify subscription create failed: " + userErrors.map((entry) => entry.message).join("; "));
5879
+ }
5880
+ ;
5881
+ if (!(payload == null ? void 0 : payload.confirmationUrl)) {
5882
+ throw new Error("Shopify subscription create failed: no confirmation url returned");
5883
+ }
5884
+ ;
5885
+ return {
5886
+ confirmationUrl: payload.confirmationUrl,
5887
+ subscription: payload.appSubscription
5888
+ };
5889
+ };
5890
+ var createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = "USD", description, domain, idempotencyKey, subscriptionLineItemId }) => {
5891
+ var _a;
5892
+ if (!subscriptionLineItemId || !description || !(Number(amount) > 0)) {
5893
+ throw new Error("createUsageRecord requires subscriptionLineItemId, description, and a positive amount");
5894
+ }
5895
+ ;
5896
+ const data = await adminFetch({
5897
+ fetcher,
5898
+ adminAccessToken,
5899
+ domain,
5900
+ query: `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
5901
+ appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
5902
+ appUsageRecord { id }
5903
+ userErrors { field message }
5904
+ }
5905
+ }`,
5906
+ variables: {
5907
+ description,
5908
+ idempotencyKey: idempotencyKey ? String(idempotencyKey) : null,
5909
+ price: { amount: Number(amount), currencyCode },
5910
+ subscriptionLineItemId
5911
+ }
5912
+ });
5913
+ const payload = data == null ? void 0 : data.appUsageRecordCreate;
5914
+ const userErrors = (payload == null ? void 0 : payload.userErrors) || [];
5915
+ if (userErrors.length || !((_a = payload == null ? void 0 : payload.appUsageRecord) == null ? void 0 : _a.id)) {
5916
+ throw new Error("Shopify usage record failed: " + (userErrors.map((entry) => entry.message).join("; ") || "no record returned"));
5917
+ }
5918
+ ;
5919
+ return { id: payload.appUsageRecord.id };
5920
+ };
5921
+ var getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
5922
+ var _a, _b;
5923
+ const data = await adminFetch({
5924
+ fetcher,
5925
+ adminAccessToken,
5926
+ domain,
5927
+ query: `{
5928
+ product( id : "${productId}" ) {
5929
+ variants( first : 100 ) {
5930
+ edges {
5931
+ node {
5932
+ id
5933
+ inventoryQuantity
5934
+ }
5935
+ }
5936
+ }
5937
+ }
5938
+ }`
5939
+ });
5940
+ const edges = ((_b = (_a = data == null ? void 0 : data.product) == null ? void 0 : _a.variants) == null ? void 0 : _b.edges) || [];
5941
+ return edges.reduce((accumulator, edge) => {
5942
+ var _a2, _b2;
5943
+ const id = (_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id;
5944
+ const quantity = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.inventoryQuantity;
5945
+ if (id) {
5946
+ accumulator[id] = Number.isFinite(quantity) ? quantity : 0;
5947
+ }
5948
+ ;
5949
+ return accumulator;
5950
+ }, {});
5951
+ };
5952
+ var getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
5953
+ var _a;
5954
+ const data = await adminFetch({
5955
+ fetcher,
5956
+ adminAccessToken,
5957
+ domain,
5958
+ query: `
5959
+ query ProductUpdatedAt($id: ID!) {
5960
+ product(id: $id) {
5961
+ updatedAt
5962
+ }
5963
+ }
5964
+ `,
5965
+ variables: { id: productId }
5966
+ });
5967
+ return ((_a = data == null ? void 0 : data.product) == null ? void 0 : _a.updatedAt) || null;
5968
+ };
5969
+ var NOT_ON_CHANNEL = "not available to the channel";
5970
+ var OUTDATED_FEEDBACK = "later version";
5971
+ var sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
5972
+ var _a, _b, _c;
5973
+ const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
5974
+ if (!productUpdatedAt) return null;
5975
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5976
+ const submit = async (version) => {
5977
+ const data = await adminFetch({
5978
+ fetcher,
5979
+ adminAccessToken,
5980
+ domain,
5981
+ query: `
5982
+ mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
5983
+ bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
5984
+ feedback {
5985
+ productId
5986
+ state
5987
+ }
5988
+ userErrors {
5989
+ field
5990
+ message
5991
+ }
5992
+ }
5993
+ }
5994
+ `,
5995
+ variables: {
5996
+ feedbackInput: [
5997
+ {
5998
+ productId,
5999
+ state,
6000
+ feedbackGeneratedAt: now,
6001
+ productUpdatedAt: version,
6002
+ messages
6003
+ }
6004
+ ]
6005
+ }
6006
+ });
6007
+ return data == null ? void 0 : data.bulkProductResourceFeedbackCreate;
6008
+ };
6009
+ let result = await submit(productUpdatedAt);
6010
+ if ((((_b = (_a = result == null ? void 0 : result.userErrors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) || "").toLowerCase().includes(OUTDATED_FEEDBACK)) {
6011
+ result = await submit(now);
6012
+ }
6013
+ ;
6014
+ const feedbackErrors = result == null ? void 0 : result.userErrors;
6015
+ if (feedbackErrors == null ? void 0 : feedbackErrors.length) {
6016
+ const message = feedbackErrors[0].message || "";
6017
+ if (message.toLowerCase().includes(NOT_ON_CHANNEL)) return null;
6018
+ throw new Error("Shopify product feedback failed: " + message);
6019
+ }
6020
+ ;
6021
+ return ((_c = result == null ? void 0 : result.feedback) == null ? void 0 : _c[0]) || null;
6022
+ };
6023
+
6024
+ // lib/shopify/billing.js
6025
+ var billing_exports = {};
6026
+ __export(billing_exports, {
6027
+ sendAppEvent: () => sendAppEvent
6028
+ });
6029
+ var APP_API = "https://api.shopify.com";
6030
+ var APP_API_VERSION = process.env.SHOPIFY_APP_API_VERSION || SHOPIFY_APP_API_VERSION;
6031
+ var cachedTokens = {};
6032
+ var fetchAppToken = async ({ clientId, clientSecret, fetcher }) => {
6033
+ const data = await request({
6034
+ fetcher,
6035
+ method: "POST",
6036
+ url: APP_API + "/auth/access_token",
6037
+ body: {
6038
+ client_id: clientId,
6039
+ client_secret: clientSecret,
6040
+ grant_type: "client_credentials"
6041
+ }
6042
+ });
6043
+ if (!(data == null ? void 0 : data.access_token)) {
6044
+ throw new Error("Shopify app token request failed: no access token returned");
6045
+ }
6046
+ ;
6047
+ const ttl = data.expires_in ? data.expires_in * 1e3 : 60 * 60 * 1e3;
6048
+ cachedTokens[clientId] = {
6049
+ expiresAt: Date.now() + ttl - 60 * 1e3,
6050
+ token: data.access_token
6051
+ };
6052
+ return data.access_token;
6053
+ };
6054
+ var getAppToken = async ({ clientId, clientSecret, fetcher }) => {
6055
+ const cached = cachedTokens[clientId];
6056
+ if (cached && cached.expiresAt > Date.now()) {
6057
+ return cached.token;
6058
+ }
6059
+ ;
6060
+ return fetchAppToken({ clientId, clientSecret, fetcher });
6061
+ };
6062
+ var toShopGid = (shopId) => String(shopId).startsWith("gid://") ? String(shopId) : "gid://shopify/Shop/" + shopId;
6063
+ var sendAppEvent = async ({ fetcher, clientId, clientSecret, eventHandle, idempotencyKey, reference: reference2, revision, shopId, timestamp, value }) => {
6064
+ if (!shopId || !eventHandle || !(Number(value) > 0)) {
6065
+ throw new Error("sendAppEvent requires shopId, eventHandle, and a positive value");
6066
+ }
6067
+ ;
6068
+ if (!clientId || !clientSecret) {
6069
+ throw new Error("sendAppEvent requires clientId and clientSecret");
6070
+ }
6071
+ ;
6072
+ const suffix = revision ? ".r" + revision : "";
6073
+ const key = idempotencyKey && String(idempotencyKey).slice(0, Math.max(0, 64 - suffix.length)) + suffix;
6074
+ const body = {
6075
+ attributes: {
6076
+ value: Number(value),
6077
+ ...reference2 && {
6078
+ reference: String(reference2).slice(0, 128)
6079
+ }
6080
+ },
6081
+ event_handle: eventHandle,
6082
+ shop_id: toShopGid(shopId),
6083
+ timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString(),
6084
+ ...key && {
6085
+ idempotency_key: key
6086
+ }
6087
+ };
6088
+ const send2 = async (token) => request({
6089
+ method: "POST",
6090
+ url: APP_API + "/app/" + APP_API_VERSION + "/events",
6091
+ headers: {
6092
+ "Authorization": "Bearer " + token
6093
+ },
6094
+ body
6095
+ });
6096
+ try {
6097
+ return await send2(await getAppToken({ clientId, clientSecret, fetcher }));
6098
+ } catch (error) {
6099
+ if ((error == null ? void 0 : error.status) === 401) {
6100
+ delete cachedTokens[clientId];
6101
+ return send2(await fetchAppToken({ clientId, clientSecret, fetcher }));
6102
+ }
6103
+ ;
6104
+ throw error;
6105
+ }
6106
+ };
6107
+
6108
+ // lib/shopify/oauth.js
6109
+ var oauth_exports = {};
6110
+ __export(oauth_exports, {
6111
+ SHOPIFY_REQUIRED_SCOPES: () => SHOPIFY_REQUIRED_SCOPES,
6112
+ createStorefrontToken: () => createStorefrontToken,
6113
+ getAdminToken: () => getAdminToken,
6114
+ getShop: () => getShop,
6115
+ missingScopes: () => missingScopes2,
6116
+ ping: () => ping,
6117
+ refreshAdminToken: () => refreshAdminToken,
6118
+ resolveConnectionSettings: () => resolveConnectionSettings
6119
+ });
6120
+ var required = (name, value) => {
6121
+ if (!value) {
6122
+ throw new Error("Shopify " + name + " is required");
6123
+ }
6124
+ ;
6125
+ return value;
6126
+ };
6127
+ var missingScopes2 = (granted) => {
6128
+ const grantedSet = new Set(
6129
+ (Array.isArray(granted) ? granted : String(granted || "").split(",")).map((scope) => scope.trim()).filter(Boolean)
6130
+ );
6131
+ const satisfied = (scope) => grantedSet.has(scope) || grantedSet.has(scope.replace(/(^|_)read_/, "$1write_"));
6132
+ return SHOPIFY_REQUIRED_SCOPES.filter((scope) => !satisfied(scope));
6133
+ };
6134
+ var ping = async ({ fetcher, adminAccessToken, domain }) => {
6135
+ var _a;
6136
+ const response = await fetch(
6137
+ `https://${domain}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`,
6138
+ {
6139
+ body: JSON.stringify({ query: "{ shop { name } }" }),
6140
+ headers: {
6141
+ "Content-Type": "application/json",
6142
+ "X-Shopify-Access-Token": adminAccessToken
6143
+ },
6144
+ method: "POST"
6145
+ }
6146
+ );
6147
+ if (!response.ok) {
6148
+ const text = await response.text().catch(() => "");
6149
+ const error = new Error(response.status + ": " + text);
6150
+ error.status = response.status;
6151
+ throw error;
6152
+ }
6153
+ ;
6154
+ const { errors } = await response.json().catch(() => ({}));
6155
+ if (errors == null ? void 0 : errors.length) {
6156
+ throw new Error("GraphQL: " + (((_a = errors[0]) == null ? void 0 : _a.message) || "unknown error"));
6157
+ }
6158
+ ;
6159
+ };
6160
+ var OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
6161
+ var parseOAuthErrorCode = (text) => {
6162
+ var _a;
6163
+ try {
6164
+ const parsed = JSON.parse(text);
6165
+ if (parsed == null ? void 0 : parsed.error) return parsed.error;
6166
+ } catch {
6167
+ }
6168
+ ;
6169
+ return ((_a = text.match(OAUTH_ERROR_TITLE_PATTERN)) == null ? void 0 : _a[1]) || null;
6170
+ };
6171
+ var shopifyOAuthFetch = async (url, body) => {
6172
+ const response = await fetch(url, {
6173
+ method: "POST",
6174
+ headers: {
6175
+ "Content-Type": "application/json"
6176
+ },
6177
+ body: JSON.stringify(body)
6178
+ });
6179
+ if (!response.ok) {
6180
+ const text = await response.text().catch(() => "");
6181
+ const error = new Error(response.status + ": " + text);
6182
+ error.status = response.status;
6183
+ error.code = parseOAuthErrorCode(text);
6184
+ throw error;
6185
+ }
6186
+ ;
6187
+ return response.json();
6188
+ };
6189
+ var getShop = async ({ fetcher, adminAccessToken, shop }) => {
6190
+ const data = await adminFetch({
6191
+ fetcher,
6192
+ adminAccessToken,
6193
+ domain: shop,
6194
+ query: `{
6195
+ shop {
6196
+ currencyCode
6197
+ id
6198
+ myshopifyDomain
6199
+ name
6200
+ }
6201
+ }`
6202
+ });
6203
+ const shopData = data == null ? void 0 : data.shop;
6204
+ if (!(shopData == null ? void 0 : shopData.id)) {
6205
+ throw new Error("Shopify shop lookup failed: no shop returned");
6206
+ }
6207
+ ;
6208
+ return {
6209
+ currency: shopData.currencyCode || null,
6210
+ id: shopData.id.split("/").pop(),
6211
+ myshopifyDomain: shopData.myshopifyDomain,
6212
+ name: shopData.name
6213
+ };
6214
+ };
6215
+ var createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
6216
+ var _a, _b, _c, _d, _e;
6217
+ try {
6218
+ const listed = await adminFetch({
6219
+ fetcher,
6220
+ adminAccessToken,
6221
+ domain: shop,
6222
+ query: `{
6223
+ shop {
6224
+ storefrontAccessTokens(first: 100) {
6225
+ edges {
6226
+ node {
6227
+ id
6228
+ title
6229
+ }
6230
+ }
6231
+ }
6232
+ }
6233
+ }`
6234
+ });
6235
+ 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);
6236
+ const orphaned = existing.filter((token) => (token == null ? void 0 : token.title) === "Drawbridge");
6237
+ await Promise.allSettled(
6238
+ orphaned.map((token) => adminFetch({
6239
+ adminAccessToken,
6240
+ domain: shop,
6241
+ query: `
6242
+ mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
6243
+ storefrontAccessTokenDelete(input: $input) {
6244
+ deletedStorefrontAccessTokenId
6245
+ userErrors {
6246
+ field
6247
+ message
6248
+ }
6249
+ }
6250
+ }
6251
+ `,
6252
+ variables: {
6253
+ input: {
6254
+ id: token.id
6255
+ }
6256
+ }
6257
+ }))
6258
+ );
6259
+ } catch {
6260
+ }
6261
+ const created = await adminFetch({
6262
+ fetcher,
6263
+ adminAccessToken,
6264
+ domain: shop,
6265
+ query: `
6266
+ mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
6267
+ storefrontAccessTokenCreate(input: $input) {
6268
+ storefrontAccessToken {
6269
+ accessToken
6270
+ }
6271
+ userErrors {
6272
+ field
6273
+ message
6274
+ }
6275
+ }
6276
+ }
6277
+ `,
6278
+ variables: {
6279
+ input: {
6280
+ title: "Drawbridge"
6281
+ }
6282
+ }
6283
+ });
6284
+ const createErrors = (_c = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _c.userErrors;
6285
+ if (createErrors == null ? void 0 : createErrors.length) {
6286
+ throw new Error("Shopify storefront token create failed: " + createErrors[0].message);
6287
+ }
6288
+ ;
6289
+ return ((_e = (_d = created == null ? void 0 : created.storefrontAccessTokenCreate) == null ? void 0 : _d.storefrontAccessToken) == null ? void 0 : _e.accessToken) || null;
6290
+ };
6291
+ var resolveTokenContext = async ({ connection, controller }) => {
6292
+ const decrypted = (connection == null ? void 0 : connection.settings) ? decrypt(connection.settings) : {};
6293
+ if ((decrypted == null ? void 0 : decrypted.ref) !== "shop") {
6294
+ return {
6295
+ raw: (connection == null ? void 0 : connection.settings) || null,
6296
+ session: null,
6297
+ settings: decrypted,
6298
+ shop: null,
6299
+ target: "connection"
6300
+ };
6301
+ }
6302
+ ;
6303
+ const shop = decrypted.shop;
6304
+ if (!controller || !shop) {
6305
+ return { raw: null, session: {}, settings: {}, shop, target: "shop" };
6306
+ }
6307
+ ;
6308
+ const record = await controller.get({ collection: "shop", query: { shop } });
6309
+ const session = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : {};
6310
+ const storefront = (record == null ? void 0 : record.storefront) ? decrypt(record.storefront) : null;
6311
+ return {
6312
+ raw: (record == null ? void 0 : record.settings) || null,
6313
+ session,
6314
+ settings: {
6315
+ adminAccessToken: session.accessToken || null,
6316
+ refreshToken: session.refreshToken || null,
6317
+ refreshTokenExpiresAt: session.refreshTokenExpires || null,
6318
+ storefrontAccessToken: (storefront == null ? void 0 : storefront.token) || null,
6319
+ tokenExpiresAt: session.expires || null
6320
+ },
6321
+ shop,
6322
+ target: "shop"
6323
+ };
6324
+ };
6325
+ var resolveConnectionSettings = async ({ fetcher, connection, controller }) => {
6326
+ const { settings } = await resolveTokenContext({ connection, controller });
6327
+ return settings;
6328
+ };
6329
+ var refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
6330
+ var _a;
6331
+ const { raw, session, settings, shop, target } = await resolveTokenContext({ connection, controller });
6332
+ const domain = (connection == null ? void 0 : connection.shop) || ((_a = settings.source) == null ? void 0 : _a.domain) || settings.domain || shop;
6333
+ const { refreshToken } = settings;
6334
+ const data = await shopifyOAuthFetch(
6335
+ `https://${domain}/admin/oauth/access_token`,
6336
+ {
6337
+ grant_type: "refresh_token",
6338
+ // Throw before the grant: rotation RETIRES the old pair the moment it
6339
+ // returns, so a credential-less call that Shopify rejects opaquely is
6340
+ // the one failure mode worth being loud about.
6341
+ client_id: required("clientId", clientId),
6342
+ client_secret: required("clientSecret", clientSecret),
6343
+ refresh_token: refreshToken
6344
+ }
6345
+ );
6346
+ const adminAccessToken = data.access_token;
6347
+ const newRefreshToken = data.refresh_token;
6348
+ const expiresIn = data.expires_in;
6349
+ const tokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : null;
6350
+ const refreshTokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS);
6351
+ let liveToken = adminAccessToken;
6352
+ if (target === "shop") {
6353
+ const written = await controller.update({
6354
+ collection: "shop",
6355
+ data: {
6356
+ $set: {
6357
+ settings: encrypt({
6358
+ ...session,
6359
+ accessToken: adminAccessToken,
6360
+ expires: tokenExpiresAt,
6361
+ refreshToken: newRefreshToken,
6362
+ refreshTokenExpires: refreshTokenExpiresAt
6363
+ })
6364
+ }
6365
+ },
6366
+ query: {
6367
+ shop,
6368
+ ...raw && { settings: raw }
6369
+ }
6370
+ });
6371
+ if (!written) {
6372
+ const current = await controller.get({ collection: "shop", query: { shop } });
6373
+ if (!(current == null ? void 0 : current.settings)) {
6374
+ throw new Error("Shopify token refresh write-back failed: shop record missing for " + shop);
6375
+ }
6376
+ ;
6377
+ const currentSession = decrypt(current.settings);
6378
+ if ((currentSession == null ? void 0 : currentSession.accessToken) && currentSession.accessToken !== settings.adminAccessToken) {
6379
+ liveToken = currentSession.accessToken;
6380
+ } else {
6381
+ throw new Error("Shopify token refresh write-back failed for " + shop);
6382
+ }
6383
+ ;
6384
+ }
6385
+ ;
6386
+ } else {
6387
+ const written = await controller.update({
6388
+ collection: "connection",
6389
+ data: {
6390
+ $set: {
6391
+ settings: encrypt({
6392
+ ...settings,
6393
+ adminAccessToken,
6394
+ refreshToken: newRefreshToken,
6395
+ refreshTokenExpiresAt,
6396
+ tokenExpiresAt
6397
+ })
6398
+ }
6399
+ },
6400
+ query: {
6401
+ id: connection.id,
6402
+ ...raw && { settings: raw }
6403
+ }
6404
+ });
6405
+ if (!written) {
6406
+ const current = await controller.get({ collection: "connection", query: { id: connection.id } });
6407
+ const currentSettings = (current == null ? void 0 : current.settings) ? decrypt(current.settings) : null;
6408
+ if ((currentSettings == null ? void 0 : currentSettings.adminAccessToken) && currentSettings.adminAccessToken !== settings.adminAccessToken) {
6409
+ liveToken = currentSettings.adminAccessToken;
6410
+ } else {
6411
+ throw new Error("Shopify token refresh write-back failed for connection " + (connection == null ? void 0 : connection.id));
6412
+ }
6413
+ ;
6414
+ }
6415
+ ;
6416
+ }
6417
+ ;
6418
+ await ping({ adminAccessToken: liveToken, domain });
6419
+ return liveToken;
6420
+ };
6421
+ var getAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
6422
+ required("clientId", clientId);
6423
+ required("clientSecret", clientSecret);
6424
+ const settings = await resolveConnectionSettings({ connection, controller });
6425
+ const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
6426
+ if (!adminAccessToken) {
6427
+ return null;
6428
+ }
6429
+ ;
6430
+ if (!refreshToken) {
6431
+ return adminAccessToken;
6432
+ }
6433
+ ;
6434
+ const needsRefresh = !tokenExpiresAt || new Date(tokenExpiresAt) < new Date(Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS);
6435
+ if (needsRefresh) {
6436
+ return refreshAdminToken({ clientId, clientSecret, connection, controller });
6437
+ }
6438
+ ;
6439
+ return adminAccessToken;
6440
+ };
6441
+
6442
+ // lib/shopify/partner.js
6443
+ var partner_exports = {};
6444
+ __export(partner_exports, {
6445
+ getUsageChargeEvents: () => getUsageChargeEvents
6446
+ });
6447
+ var PARTNER_API = "https://partners.shopify.com";
6448
+ var getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
6449
+ var _a, _b, _c;
6450
+ if (!partnerToken || !organizationId || !appId) {
6451
+ throw new Error("getUsageChargeEvents requires partnerToken, organizationId, and appId");
6452
+ }
6453
+ ;
6454
+ const data = await request({
6455
+ fetcher,
6456
+ method: "POST",
6457
+ url: PARTNER_API + "/" + organizationId + "/api/" + PARTNER_API_VERSION + "/graphql.json",
6458
+ headers: {
6459
+ "X-Shopify-Access-Token": partnerToken
6460
+ },
6461
+ body: {
6462
+ query: `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
6463
+ events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
6464
+ edges {
6465
+ node {
6466
+ id
6467
+ occurredAt
6468
+ eventType
6469
+ shop { id myshopifyDomain }
6470
+ ... on Charge {
6471
+ chargeId
6472
+ chargeType
6473
+ amount { amount currencyCode }
6474
+ usageQuantity
6475
+ planHandle
6476
+ description
6477
+ }
6478
+ }
6479
+ }
6480
+ pageInfo { hasNextPage endCursor }
6481
+ }
6482
+ }`,
6483
+ variables: {
6484
+ filter: {
6485
+ eventTypes: ["CHARGE_USAGE"],
6486
+ subjectId: String(appId),
6487
+ ...occurredAtMin && { occurredAtMin },
6488
+ ...shopId && { shopId: String(shopId) }
6489
+ },
6490
+ first
6491
+ }
6492
+ }
6493
+ });
6494
+ if ((_a = data == null ? void 0 : data.errors) == null ? void 0 : _a.length) {
6495
+ throw new Error("Shopify partner events query failed: " + data.errors.map((entry) => entry.message).join("; "));
6496
+ }
6497
+ ;
6498
+ return (((_c = (_b = data == null ? void 0 : data.data) == null ? void 0 : _b.events) == null ? void 0 : _c.edges) || []).map((edge) => {
6499
+ var _a2, _b2, _c2, _d, _e, _f, _g, _h, _i, _j;
6500
+ return {
6501
+ amount: ((_a2 = edge.node) == null ? void 0 : _a2.amount) || null,
6502
+ chargeId: ((_b2 = edge.node) == null ? void 0 : _b2.chargeId) || null,
6503
+ // USAGE vs the recurring plan charge an approval emits — the
6504
+ // disambiguator callers filter on.
6505
+ chargeType: ((_c2 = edge.node) == null ? void 0 : _c2.chargeType) || null,
6506
+ description: ((_d = edge.node) == null ? void 0 : _d.description) || null,
6507
+ eventType: ((_e = edge.node) == null ? void 0 : _e.eventType) || null,
6508
+ id: ((_f = edge.node) == null ? void 0 : _f.id) || null,
6509
+ occurredAt: ((_g = edge.node) == null ? void 0 : _g.occurredAt) || null,
6510
+ planHandle: ((_h = edge.node) == null ? void 0 : _h.planHandle) || null,
6511
+ shop: ((_i = edge.node) == null ? void 0 : _i.shop) || null,
6512
+ usageQuantity: ((_j = edge.node) == null ? void 0 : _j.usageQuantity) ?? null
6513
+ };
6514
+ });
6515
+ };
6516
+
6517
+ // lib/shopify/storefront.js
6518
+ var storefront_exports = {};
6519
+ __export(storefront_exports, {
6520
+ cartCreate: () => cartCreate,
6521
+ cartGet: () => cartGet,
6522
+ cartLinesAdd: () => cartLinesAdd,
6523
+ cartLinesRemove: () => cartLinesRemove,
6524
+ cartLinesUpdate: () => cartLinesUpdate,
6525
+ createCheckoutToken: () => createCheckoutToken,
6526
+ getProduct: () => getProduct,
6527
+ getProductVariantsPage: () => getProductVariantsPage,
6528
+ getProducts: () => getProducts,
6529
+ probeVariantsOutOfStock: () => probeVariantsOutOfStock
6530
+ });
6531
+ var SORT_KEY_MAP = {
6532
+ bestSelling: "BEST_SELLING",
6533
+ createdAt: "CREATED_AT",
6534
+ id: "ID",
6535
+ price: "PRICE",
6536
+ productType: "PRODUCT_TYPE",
6537
+ title: "TITLE",
6538
+ updatedAt: "UPDATED_AT",
6539
+ vendor: "VENDOR"
6540
+ };
6541
+ var resolveProductSort = (sort) => {
6542
+ const entry = sort && Object.entries(sort)[0];
6543
+ if (!entry) return null;
6544
+ const [field, direction] = entry;
6545
+ const sortKey = SORT_KEY_MAP[field];
6546
+ if (!sortKey) return null;
6547
+ return {
6548
+ sortKey,
6549
+ reverse: Number(direction) < 0
6550
+ };
6551
+ };
6552
+ var storefrontUrl = (domain) => `https://${domain}/api/${SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`;
6553
+ var storefrontFetch = async ({ fetcher, domain, storefrontAccessToken, query, variables }) => {
6554
+ return request({
6555
+ method: "POST",
6556
+ url: storefrontUrl(domain),
6557
+ headers: {
6558
+ "X-Shopify-Storefront-Access-Token": storefrontAccessToken
6559
+ },
6560
+ body: {
6561
+ query,
6562
+ ...variables && { variables }
6563
+ }
6564
+ });
6565
+ };
6566
+ var shopCountryCache = {};
6567
+ var getShopCountry = async ({ domain, fetcher, storefrontAccessToken }) => {
6568
+ var _a, _b;
6569
+ if (shopCountryCache[domain]) return shopCountryCache[domain];
6570
+ const { data } = await storefrontFetch({
6571
+ fetcher,
6572
+ domain,
6573
+ storefrontAccessToken,
6574
+ query: `{
6575
+ shop {
6576
+ paymentSettings {
6577
+ countryCode
6578
+ }
6579
+ }
6580
+ }`
6581
+ });
6582
+ const countryCode = ((_b = (_a = data == null ? void 0 : data.shop) == null ? void 0 : _a.paymentSettings) == null ? void 0 : _b.countryCode) || null;
6583
+ if (countryCode) shopCountryCache[domain] = countryCode;
6584
+ return countryCode;
6585
+ };
6586
+ var OUT_OF_STOCK_WARNING_CODES = [
6587
+ "MERCHANDISE_OUT_OF_STOCK",
6588
+ "MERCHANDISE_NOT_ENOUGH_STOCK"
6589
+ ];
6590
+ var probeVariantsOutOfStock = async ({ fetcher, domain, storefrontAccessToken, variantIds, countryCode }) => {
6591
+ var _a, _b;
6592
+ if (!(variantIds == null ? void 0 : variantIds.length)) return [];
6593
+ const resolvedCountry = countryCode || await getShopCountry({ domain, fetcher, storefrontAccessToken });
6594
+ const { data, errors } = await storefrontFetch({
6595
+ fetcher,
6596
+ domain,
6597
+ storefrontAccessToken,
6598
+ query: `
6599
+ mutation probe( $input : CartInput! ){
6600
+ cartCreate( input : $input ){
6601
+ cart {
6602
+ lines( first : 250 ){
6603
+ edges {
6604
+ node {
6605
+ id
6606
+ merchandise {
6607
+ ... on ProductVariant {
6608
+ id
6609
+ }
6610
+ }
6611
+ }
6612
+ }
6613
+ }
6614
+ }
6615
+ warnings {
6616
+ code
6617
+ message
6618
+ target
6619
+ }
6620
+ }
6621
+ }
6622
+ `,
6623
+ variables: {
6624
+ input: {
6625
+ lines: variantIds.map((merchandiseId) => ({
6626
+ merchandiseId,
6627
+ quantity: 1
6628
+ })),
6629
+ ...resolvedCountry && {
6630
+ buyerIdentity: {
6631
+ countryCode: resolvedCountry
6632
+ }
6633
+ }
6634
+ }
6635
+ }
6636
+ });
6637
+ if (errors) return [];
6638
+ const payload = data == null ? void 0 : data.cartCreate;
6639
+ const soldOut = new Set(
6640
+ ((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)
6641
+ );
6642
+ 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 }) => {
6643
+ var _a2;
6644
+ return (_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.id;
6645
+ }).filter(Boolean);
6646
+ };
6647
+ var productsQuery = (search, cursor, limit, sort) => {
6648
+ const resolved = resolveProductSort(sort);
6649
+ const sortArgs = resolved ? `, sortKey: ${resolved.sortKey}, reverse: ${resolved.reverse}` : "";
6650
+ return `{
6651
+ products(first: ${limit}${search ? `, query: "title:*${search}*"` : ""}${cursor ? `, after: "${cursor}"` : ""}${sortArgs}) {
6652
+ edges {
6653
+ node {
6654
+ id
6655
+ title
6656
+ description
6657
+ handle
6658
+ productType
6659
+ featuredImage {
6660
+ url
6661
+ }
6662
+ variants(first: 10) {
6663
+ edges {
6664
+ node {
6665
+ id
6666
+ title
6667
+ image {
6668
+ url
6669
+ }
6670
+ price {
6671
+ amount
6672
+ currencyCode
6673
+ }
6674
+ compareAtPrice {
6675
+ amount
6676
+ currencyCode
6677
+ }
6678
+ availableForSale
6679
+ requiresShipping
6680
+ }
6681
+ }
6682
+ }
6683
+ }
6684
+ }
6685
+ pageInfo {
6686
+ endCursor
6687
+ hasNextPage
6688
+ hasPreviousPage
6689
+ startCursor
6690
+ }
6691
+ }
6692
+ }`;
6693
+ };
6694
+ var productQuery = (productId) => `{
6695
+ product(id: "${productId}") {
6696
+ id
6697
+ title
6698
+ description
6699
+ handle
6700
+ productType
6701
+ featuredImage {
6702
+ url
6703
+ }
6704
+ variants(first: 10) {
6705
+ edges {
6706
+ node {
6707
+ id
6708
+ title
6709
+ image {
6710
+ url
6711
+ }
6712
+ price {
6713
+ amount
6714
+ currencyCode
6715
+ }
6716
+ compareAtPrice {
6717
+ amount
6718
+ currencyCode
6719
+ }
6720
+ availableForSale
6721
+ requiresShipping
6722
+ }
6723
+ }
6724
+ }
6725
+ }
6726
+ }`;
6727
+ var variantsQuery = (productId, cursor) => `{
6728
+ product(id: "${productId}") {
6729
+ variants(first: 250${cursor ? `, after: "${cursor}"` : ""}) {
6730
+ edges {
6731
+ node {
6732
+ id
6733
+ title
6734
+ image {
6735
+ url
6736
+ }
6737
+ price {
6738
+ amount
6739
+ currencyCode
6740
+ }
6741
+ compareAtPrice {
6742
+ amount
6743
+ currencyCode
6744
+ }
6745
+ availableForSale
6746
+ requiresShipping
6747
+ }
6748
+ }
6749
+ pageInfo {
6750
+ hasNextPage
6751
+ endCursor
6752
+ }
6753
+ }
6754
+ }
6755
+ }`;
6756
+ var getProducts = async ({ fetcher, domain, storefrontAccessToken, search, cursor, limit, sort }) => {
6757
+ const { data, errors } = await storefrontFetch({
6758
+ fetcher,
6759
+ domain,
6760
+ storefrontAccessToken,
6761
+ query: productsQuery(search, cursor, limit, sort)
6762
+ });
6763
+ if (errors) {
6764
+ throw new Error("Failed to fetch Shopify products");
6765
+ }
6766
+ ;
6767
+ return data == null ? void 0 : data.products;
6768
+ };
6769
+ var getProduct = async ({ fetcher, domain, storefrontAccessToken, productId }) => {
6770
+ var _a;
6771
+ const { data, errors } = await storefrontFetch({
6772
+ fetcher,
6773
+ domain,
6774
+ storefrontAccessToken,
6775
+ query: productQuery(productId)
6776
+ });
6777
+ if (errors) {
6778
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6779
+ }
6780
+ ;
6781
+ if (!(data == null ? void 0 : data.product)) {
6782
+ throw new Error("Shopify product not found");
6783
+ }
6784
+ ;
6785
+ return data.product;
6786
+ };
6787
+ var getProductVariantsPage = async ({ fetcher, domain, storefrontAccessToken, productId, cursor }) => {
6788
+ var _a;
6789
+ const { data, errors } = await storefrontFetch({
6790
+ fetcher,
6791
+ domain,
6792
+ storefrontAccessToken,
6793
+ query: variantsQuery(productId, cursor)
6794
+ });
6795
+ if (errors) {
6796
+ throw new Error("Shopify storefront error: " + ((_a = errors[0]) == null ? void 0 : _a.message));
6797
+ }
6798
+ ;
6799
+ if (!(data == null ? void 0 : data.product)) {
6800
+ throw new Error("Shopify product not found");
6801
+ }
6802
+ ;
6803
+ return data.product.variants;
6804
+ };
6805
+ var CART_FIELDS = `
6806
+ id
6807
+ checkoutUrl
6808
+ lines(first: 100) {
6809
+ edges {
6810
+ node {
6811
+ id
6812
+ quantity
6813
+ merchandise {
6814
+ ... on ProductVariant {
6815
+ id
6816
+ title
6817
+ availableForSale
6818
+ image {
6819
+ url
6820
+ }
6821
+ price {
6822
+ amount
6823
+ currencyCode
6824
+ }
6825
+ product {
6826
+ title
6827
+ }
6828
+ }
6829
+ }
6830
+ }
6831
+ }
6832
+ }
6833
+ `;
6834
+ var mapCart = (cart) => {
6835
+ var _a;
6836
+ if (!cart) return null;
6837
+ return {
6838
+ checkoutUrl: cart.checkoutUrl,
6839
+ id: cart.id,
6840
+ lines: (((_a = cart.lines) == null ? void 0 : _a.edges) || []).map(({ node }) => {
6841
+ var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
6842
+ return {
6843
+ availableForSale: ((_a2 = node == null ? void 0 : node.merchandise) == null ? void 0 : _a2.availableForSale) ?? true,
6844
+ currency: ((_c = (_b = node == null ? void 0 : node.merchandise) == null ? void 0 : _b.price) == null ? void 0 : _c.currencyCode) || null,
6845
+ id: node.id,
6846
+ image: ((_e = (_d = node == null ? void 0 : node.merchandise) == null ? void 0 : _d.image) == null ? void 0 : _e.url) || null,
6847
+ price: ((_g = (_f = node == null ? void 0 : node.merchandise) == null ? void 0 : _f.price) == null ? void 0 : _g.amount) || null,
6848
+ productTitle: ((_i = (_h = node == null ? void 0 : node.merchandise) == null ? void 0 : _h.product) == null ? void 0 : _i.title) || null,
6849
+ quantity: node.quantity,
6850
+ variantId: (_j = node == null ? void 0 : node.merchandise) == null ? void 0 : _j.id,
6851
+ variantTitle: ((_k = node == null ? void 0 : node.merchandise) == null ? void 0 : _k.title) || null
6852
+ };
6853
+ })
6854
+ };
6855
+ };
6856
+ var firstUserError = (userErrors) => {
6857
+ const error = userErrors == null ? void 0 : userErrors[0];
6858
+ if (!error) return null;
6859
+ const message = new Error(error.message || "Shopify cart error");
6860
+ message.userError = true;
6861
+ return message;
6862
+ };
6863
+ var createCheckoutToken = async ({ fetcher, clientId, clientSecret } = {}) => {
6864
+ if (!clientId || !clientSecret) {
6865
+ throw new Error("createCheckoutToken requires clientId and clientSecret");
6866
+ }
6867
+ ;
6868
+ const response = await request({
6869
+ fetcher,
6870
+ method: "POST",
6871
+ url: "https://api.shopify.com/auth/access_token",
6872
+ body: {
6873
+ client_id: clientId,
6874
+ client_secret: clientSecret,
6875
+ grant_type: "client_credentials"
6876
+ }
6877
+ });
6878
+ const token = response == null ? void 0 : response.access_token;
6879
+ if (!token) {
6880
+ throw new Error("Failed to create Shopify checkout token");
6881
+ }
6882
+ ;
6883
+ return token;
6884
+ };
6885
+ var cartCreate = async ({ fetcher, domain, storefrontAccessToken, lines }) => {
6886
+ var _a, _b;
6887
+ const { data, errors } = await storefrontFetch({
6888
+ fetcher,
6889
+ domain,
6890
+ storefrontAccessToken,
6891
+ query: `
6892
+ mutation cartCreate($input: CartInput!) {
6893
+ cartCreate(input: $input) {
6894
+ cart {
6895
+ ${CART_FIELDS}
6896
+ }
6897
+ userErrors {
6898
+ field
6899
+ message
6900
+ }
6901
+ }
6902
+ }
6903
+ `,
6904
+ variables: {
6905
+ input: {
6906
+ lines
6907
+ }
6908
+ }
6909
+ });
6910
+ if (errors) {
6911
+ throw new Error("Failed to create Shopify cart");
6912
+ }
6913
+ ;
6914
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartCreate) == null ? void 0 : _a.userErrors);
6915
+ if (userError) throw userError;
6916
+ return mapCart((_b = data == null ? void 0 : data.cartCreate) == null ? void 0 : _b.cart);
6917
+ };
6918
+ var cartGet = async ({ fetcher, domain, storefrontAccessToken, cartId }) => {
6919
+ const { data, errors } = await storefrontFetch({
6920
+ fetcher,
6921
+ domain,
6922
+ storefrontAccessToken,
6923
+ query: `
6924
+ query cart($id: ID!) {
6925
+ cart(id: $id) {
6926
+ ${CART_FIELDS}
6927
+ }
6928
+ }
6929
+ `,
6930
+ variables: {
6931
+ id: cartId
6932
+ }
6933
+ });
6934
+ if (errors) {
6935
+ throw new Error("Failed to fetch Shopify cart");
6936
+ }
6937
+ ;
6938
+ return mapCart(data == null ? void 0 : data.cart);
6939
+ };
6940
+ var cartLinesAdd = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
6941
+ var _a, _b;
6942
+ const { data, errors } = await storefrontFetch({
6943
+ fetcher,
6944
+ domain,
6945
+ storefrontAccessToken,
6946
+ query: `
6947
+ mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
6948
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
6949
+ cart {
6950
+ ${CART_FIELDS}
6951
+ }
6952
+ userErrors {
6953
+ field
6954
+ message
6955
+ }
6956
+ }
6957
+ }
6958
+ `,
6959
+ variables: {
6960
+ cartId,
6961
+ lines
6962
+ }
6963
+ });
6964
+ if (errors) {
6965
+ throw new Error("Failed to add Shopify cart lines");
6966
+ }
6967
+ ;
6968
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _a.userErrors);
6969
+ if (userError) throw userError;
6970
+ return mapCart((_b = data == null ? void 0 : data.cartLinesAdd) == null ? void 0 : _b.cart);
6971
+ };
6972
+ var cartLinesRemove = async ({ fetcher, domain, storefrontAccessToken, cartId, lineIds }) => {
6973
+ var _a, _b;
6974
+ const { data, errors } = await storefrontFetch({
6975
+ fetcher,
6976
+ domain,
6977
+ storefrontAccessToken,
6978
+ query: `
6979
+ mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
6980
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
6981
+ cart {
6982
+ ${CART_FIELDS}
6983
+ }
6984
+ userErrors {
6985
+ field
6986
+ message
6987
+ }
6988
+ }
6989
+ }
6990
+ `,
6991
+ variables: {
6992
+ cartId,
6993
+ lineIds
6994
+ }
6995
+ });
6996
+ if (errors) {
6997
+ throw new Error("Failed to remove Shopify cart lines");
6998
+ }
6999
+ ;
7000
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _a.userErrors);
7001
+ if (userError) throw userError;
7002
+ return mapCart((_b = data == null ? void 0 : data.cartLinesRemove) == null ? void 0 : _b.cart);
7003
+ };
7004
+ var cartLinesUpdate = async ({ fetcher, domain, storefrontAccessToken, cartId, lines }) => {
7005
+ var _a, _b;
7006
+ const { data, errors } = await storefrontFetch({
7007
+ fetcher,
7008
+ domain,
7009
+ storefrontAccessToken,
7010
+ query: `
7011
+ mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
7012
+ cartLinesUpdate(cartId: $cartId, lines: $lines) {
7013
+ cart {
7014
+ ${CART_FIELDS}
7015
+ }
7016
+ userErrors {
7017
+ field
7018
+ message
7019
+ }
7020
+ }
7021
+ }
7022
+ `,
7023
+ variables: {
7024
+ cartId,
7025
+ lines
7026
+ }
7027
+ });
7028
+ if (errors) {
7029
+ throw new Error("Failed to update Shopify cart lines");
7030
+ }
7031
+ ;
7032
+ const userError = firstUserError((_a = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _a.userErrors);
7033
+ if (userError) throw userError;
7034
+ return mapCart((_b = data == null ? void 0 : data.cartLinesUpdate) == null ? void 0 : _b.cart);
7035
+ };
7036
+
7037
+ // lib/email.js
7038
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
7039
+ var toCanonicalEmail = (value) => {
7040
+ if (!value || typeof value !== "string") return null;
7041
+ const email = value.trim().toLowerCase();
7042
+ const at = email.lastIndexOf("@");
7043
+ if (at < 1 || at === email.length - 1) return null;
7044
+ let local = email.slice(0, at);
7045
+ const domain = email.slice(at + 1);
7046
+ const plus = local.indexOf("+");
7047
+ if (plus > 0) local = local.slice(0, plus);
7048
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
7049
+ if (!local) return null;
7050
+ return local + "@" + domain;
7051
+ };
7052
+
7053
+ // lib/oauth/index.js
7054
+ var lifetimes = {
7055
+ access: 60 * 60 * 8,
7056
+ // 8 hours
7057
+ authorizationCode: 10 * 60,
7058
+ // 10 minutes
7059
+ code: 15 * 60,
7060
+ // 15 minutes (email OTC)
7061
+ refresh: 30 * 24 * 60 * 60
7062
+ // 30 days
7063
+ };
7064
+ var tokenTypes = {
7065
+ access: "access",
7066
+ oauthState: "oauth.state",
7067
+ pat: "pat",
7068
+ refresh: "refresh"
7069
+ };
7070
+ var scopes = [
7071
+ "profile:read",
7072
+ "profile:write",
7073
+ "organization:read",
7074
+ "organization:write",
7075
+ "campaigns:read",
7076
+ "campaigns:write",
7077
+ "contacts:read",
7078
+ "contacts:write",
7079
+ "workflows:read",
7080
+ "workflows:write",
7081
+ "connections:read",
7082
+ "connections:write",
7083
+ "billing:read",
7084
+ "billing:write",
7085
+ "admin"
7086
+ ];
7087
+ var developer = [
7088
+ "profile:read",
7089
+ "organization:read",
7090
+ "campaigns:read",
7091
+ "campaigns:write",
7092
+ "contacts:read",
7093
+ "contacts:write",
7094
+ "workflows:read",
7095
+ "workflows:write",
7096
+ "connections:read",
7097
+ "connections:write"
7098
+ ];
7099
+ var scopeLabels = {
7100
+ "profile:read": "Read own profile",
7101
+ "profile:write": "Update own profile",
7102
+ "organization:read": "Read organization info",
7103
+ "organization:write": "Manage organization",
7104
+ "campaigns:read": "Read campaigns",
7105
+ "campaigns:write": "Manage campaigns",
7106
+ "contacts:read": "Read contacts",
7107
+ "contacts:write": "Manage contacts",
7108
+ "workflows:read": "Read workflows",
7109
+ "workflows:write": "Manage workflows",
7110
+ "connections:read": "Read integrations",
7111
+ "connections:write": "Manage integrations",
7112
+ "billing:read": "Read billing",
7113
+ "billing:write": "Manage billing",
7114
+ admin: "Admin access"
7115
+ };
7116
+ var toOption = (scope) => ({
7117
+ key: scopeLabels[scope] || scope,
7118
+ value: scope
7119
+ });
7120
+ var developerOptions = developer.map(toOption);
7121
+ var scopeOptions = scopes.map(toOption);
7122
+ var hashToken = (raw) => hash(raw, process.env.HMAC_OAUTH_TOKEN_KEY);
7123
+
7124
+ // lib/sanitize.js
7125
+ var import_disposable_email_domains = __toESM(require("disposable-email-domains/index.js"), 1);
7126
+ var disposableBlacklist = new Set(import_disposable_email_domains.default.map((d) => d.toLowerCase()));
7127
+ var sanitizeDomain = (value, pattern) => {
7128
+ if (!value) return value;
7129
+ const cleaned = value.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
7130
+ if (pattern) {
7131
+ const match = cleaned.match(pattern);
7132
+ return match ? match[0] : cleaned;
7133
+ }
7134
+ return cleaned;
7135
+ };
7136
+
7137
+ // lib/slugify.js
7138
+ var import_slugify = __toESM(require("slugify"), 1);
7139
+ var slugify = (value, nochars = false) => {
7140
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
7141
+ const replacement = nochars ? "" : "-";
7142
+ return (0, import_slugify.default)(
7143
+ value,
7144
+ {
7145
+ remove,
7146
+ replacement,
7147
+ lower: true,
7148
+ trim: true
7149
+ }
7150
+ );
7151
+ };
7152
+
7153
+ // lib/connections/manifests/shopify.js
7154
+ var client = Object.freeze({
7155
+ admin: admin_exports,
7156
+ billing: billing_exports,
7157
+ oauth: oauth_exports,
7158
+ partner: partner_exports,
7159
+ storefront: storefront_exports
7160
+ });
7161
+ var toLine = ({
7162
+ price,
7163
+ product_id: productId,
7164
+ quantity,
7165
+ title,
7166
+ variant_id: variantId,
7167
+ variant_title: variantTitle
7168
+ }) => ({
7169
+ price: parseFloat(price) || 0,
7170
+ productId: productId ? "gid://shopify/Product/" + productId : null,
7171
+ quantity: quantity || 1,
7172
+ title: title || null,
7173
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
7174
+ variantTitle: variantTitle || null
7175
+ });
7176
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
7177
+ (acc, item) => {
7178
+ const attrs = (item.properties || []).reduce(
7179
+ (map, { name, value }) => {
7180
+ map[name] = value;
7181
+ return map;
7182
+ },
7183
+ {}
7184
+ );
7185
+ if (!attrs["_drwbrdg_ca"]) return acc;
7186
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
7187
+ const line = toLine(item);
7188
+ acc.attributedGross += line.price * line.quantity;
7189
+ acc.attributedLines.push(line);
7190
+ return acc;
7191
+ },
7192
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
7193
+ );
7194
+ var generateDiscountCode = (0, import_nanoid.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
7195
+ var blockedReason = (discount) => {
7196
+ var _a;
7197
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
7198
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
7199
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
7200
+ 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.";
7201
+ }
7202
+ ;
7203
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
7204
+ return "This discount has reached its total usage limit.";
7205
+ }
7206
+ ;
7207
+ return null;
7208
+ };
7209
+ var discountWarning = (discount) => {
7210
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
7211
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
7212
+ }
7213
+ ;
7214
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
7215
+ return null;
7216
+ };
7217
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
7218
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
7219
+ var OAUTH_ERROR_SOURCE = "oauth";
7220
+ var BILLING_ERROR_SOURCE = "billing";
7221
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
7222
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
7223
+ var inbound = {
7224
+ headers: {
7225
+ event: "x-shopify-topic",
7226
+ id: "x-shopify-webhook-id",
7227
+ shop: "x-shopify-shop-domain",
7228
+ signature: "x-shopify-hmac-sha256"
7229
+ },
7230
+ signature: {
7231
+ algorithm: "sha256",
7232
+ encoding: "base64",
7233
+ secret: "SHOPIFY_API_SECRET"
7234
+ }
7235
+ };
7236
+ var COMPLIANCE_TOPICS = /* @__PURE__ */ new Set([
7237
+ "customers/data_request",
7238
+ "customers/redact",
5229
7239
  "shop/redact"
5230
7240
  ]);
7241
+ var refusal = (message, status) => Object.assign(new Error(message), { status });
7242
+ var LINK_TOKEN_TTL_MS = 30 * 60 * 1e3;
7243
+ var CONNECTABLE_STATUSES = ["active", "unsubscribed"];
7244
+ var merchantOrganizations = ({ read, user }) => read.aggregate({
7245
+ collection: "organization",
7246
+ pipeline: [
7247
+ // A member qualifies only at `manage`; an owner always qualifies.
7248
+ {
7249
+ $lookup: {
7250
+ as: "membership",
7251
+ from: "member",
7252
+ let: { organization: "$id" },
7253
+ pipeline: [
7254
+ {
7255
+ $match: {
7256
+ $expr: { $eq: ["$organization", "$$organization"] },
7257
+ "capabilities.connections": "manage",
7258
+ status: "accepted",
7259
+ user
7260
+ }
7261
+ },
7262
+ { $limit: 1 },
7263
+ { $project: { _id: 0, organization: 1 } }
7264
+ ]
7265
+ }
7266
+ },
7267
+ {
7268
+ $match: {
7269
+ status: { $ne: "canceled" },
7270
+ $or: [{ owner: user }, { "membership.0": { $exists: true } }]
7271
+ }
7272
+ },
7273
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
7274
+ // the list can read the real plan and conversion rate. Without it every org
7275
+ // rendered as free tier.
7276
+ { $lookup: { as: "subscription", foreignField: "id", from: "subscription", localField: "subscription" } },
7277
+ { $unwind: { path: "$subscription", preserveNullAndEmptyArrays: true } },
7278
+ {
7279
+ $lookup: {
7280
+ as: "connection",
7281
+ foreignField: "organization",
7282
+ from: "connection",
7283
+ localField: "id",
7284
+ pipeline: [
7285
+ { $match: { slug: "shopify" } },
7286
+ { $limit: 1 },
7287
+ { $project: { _id: 0, id: 1, shop: 1, status: 1 } }
7288
+ ]
7289
+ }
7290
+ },
7291
+ { $unwind: { path: "$connection", preserveNullAndEmptyArrays: true } },
7292
+ { $project: { _id: 0, billingStatus: "$status", connection: 1, id: 1, name: "$title", subscription: 1 } },
7293
+ { $sort: { name: 1 } }
7294
+ ]
7295
+ });
7296
+ var storePlan = async ({ record, shopify }) => {
7297
+ var _a;
7298
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7299
+ if (!(settings == null ? void 0 : settings.accessToken)) return { active: false, name: null };
7300
+ try {
7301
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7302
+ adminAccessToken: settings.accessToken,
7303
+ domain: record.shop
7304
+ });
7305
+ const active = (subscriptions || []).length > 0;
7306
+ const metered = active && subscriptions.some((subscription) => subscription.metered === true) ? true : null;
7307
+ return { active, metered, name: active ? ((_a = subscriptions[0]) == null ? void 0 : _a.name) || null : null };
7308
+ } catch (_) {
7309
+ return { active: false, name: null, unavailable: true };
7310
+ }
7311
+ };
7312
+ var releaseApprovals = async ({ context, read, shopify }) => {
7313
+ var _a;
7314
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
7315
+ if (!shop) throw refusal("shop is required", 400);
7316
+ const record = await read.get({ collection: "shop", query: { shop } });
7317
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
7318
+ if (!(settings == null ? void 0 : settings.accessToken)) throw refusal("Store is not installed", 409);
7319
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
7320
+ adminAccessToken: settings.accessToken,
7321
+ domain: shop
7322
+ });
7323
+ for (const subscription of subscriptions || []) {
7324
+ const data = await shopify.admin.adminFetch({
7325
+ adminAccessToken: settings.accessToken,
7326
+ domain: shop,
7327
+ query: "mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }",
7328
+ variables: { id: subscription.id }
7329
+ });
7330
+ const [userError] = ((_a = data == null ? void 0 : data.appSubscriptionCancel) == null ? void 0 : _a.userErrors) || [];
7331
+ if (userError) throw refusal(userError.message, 422);
7332
+ }
7333
+ return {
7334
+ message: "Released " + (subscriptions || []).length + " approval(s).",
7335
+ request: { shop },
7336
+ result: { ok: true }
7337
+ };
7338
+ };
7339
+ var LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
7340
+ var meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
7341
+ var _a;
7342
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain: shop });
7343
+ let metered = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
7344
+ if (!((partner == null ? void 0 : partner.partnerToken) && (partner == null ? void 0 : partner.partnerOrgId) && (partner == null ? void 0 : partner.partnerAppId) && shopId)) {
7345
+ return { boundaries: [], ledger: false, metered };
7346
+ }
7347
+ const accruals = await shopify.partner.getUsageChargeEvents({
7348
+ appId: "gid://shopify/App/" + partner.partnerAppId,
7349
+ first: 10,
7350
+ occurredAtMin: ledgerFloor.toISOString(),
7351
+ organizationId: partner.partnerOrgId,
7352
+ partnerToken: partner.partnerToken,
7353
+ shopId: "gid://shopify/Shop/" + shopId
7354
+ });
7355
+ const boundaries = (accruals || []).filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
7356
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
7357
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
7358
+ return { boundaries, ledger: true, metered };
7359
+ };
7360
+ var toVariant = (edge) => {
7361
+ var _a, _b, _c, _d;
7362
+ return {
7363
+ availableForSale: edge.node.availableForSale || false,
7364
+ currency: ((_a = edge.node.price) == null ? void 0 : _a.currencyCode) || null,
7365
+ id: edge.node.id,
7366
+ image: ((_b = edge.node.image) == null ? void 0 : _b.url) || null,
7367
+ price: parseFloat((_c = edge.node.price) == null ? void 0 : _c.amount) || null,
7368
+ requiresShipping: edge.node.requiresShipping || false,
7369
+ sale: parseFloat((_d = edge.node.compareAtPrice) == null ? void 0 : _d.amount) || null,
7370
+ title: edge.node.title !== "Default Title" ? edge.node.title : null
7371
+ };
7372
+ };
7373
+ var applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
7374
+ try {
7375
+ const outOfStock = new Set(await shopify.storefront.probeVariantsOutOfStock({
7376
+ domain,
7377
+ storefrontAccessToken,
7378
+ variantIds: variants.map((variant) => variant.id)
7379
+ }));
7380
+ return variants.map((variant) => ({
7381
+ ...variant,
7382
+ availableForSale: variant.availableForSale && !outOfStock.has(variant.id)
7383
+ }));
7384
+ } catch (_) {
7385
+ return variants;
7386
+ }
7387
+ };
7388
+ var sendFeedback = async ({ adminToken, connection, messages, productId, shopify, state }) => {
7389
+ try {
7390
+ await shopify.admin.sendProductResourceFeedback({
7391
+ adminAccessToken: await adminToken(),
7392
+ domain: connection.shop,
7393
+ messages,
7394
+ productId,
7395
+ state
7396
+ });
7397
+ return null;
7398
+ } catch (error) {
7399
+ return error;
7400
+ }
7401
+ };
7402
+ var resolveProduct = async ({ connection, context, read }) => {
7403
+ const byId = (context == null ? void 0 : context.product) ? await read.get({ collection: "product", query: { id: context.product } }) : null;
7404
+ const row2 = byId || ((context == null ? void 0 : context.providerId) && ((context == null ? void 0 : context.shop) || (connection == null ? void 0 : connection.shop)) ? await read.get({
7405
+ collection: "product",
7406
+ query: {
7407
+ "provider.id": context.providerId,
7408
+ "provider.slug": "shopify",
7409
+ "source.domain": context.shop || connection.shop
7410
+ }
7411
+ }) : null);
7412
+ return row2;
7413
+ };
7414
+ var UNAVAILABLE_FEEDBACK = "Drawbridge can't display this product. Check it is active and available in the United States.";
7415
+ var NOT_FOUND = "Shopify product not found";
7416
+ var syncProduct = async ({ adminToken, connection, context, read, settings, shopify }) => {
7417
+ var _a, _b, _c;
7418
+ const row2 = await resolveProduct({ connection, context, read });
7419
+ if (!row2) return { message: "No product row to sync.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7420
+ const domain = connection.shop;
7421
+ const providerId = (_a = row2.provider) == null ? void 0 : _a.id;
7422
+ const request2 = { product: row2.id, providerId: providerId || null, shop: domain };
7423
+ let product;
7424
+ try {
7425
+ product = await shopify.storefront.getProduct({
7426
+ domain,
7427
+ productId: providerId,
7428
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7429
+ });
7430
+ } catch (error) {
7431
+ if (error.message !== NOT_FOUND) throw error;
7432
+ const failed2 = await sendFeedback({
7433
+ adminToken,
7434
+ connection,
7435
+ messages: [UNAVAILABLE_FEEDBACK],
7436
+ productId: providerId,
7437
+ shopify,
7438
+ state: "REQUIRES_ACTION"
7439
+ });
7440
+ return {
7441
+ failed: failed2 ? [failed2.message] : void 0,
7442
+ message: "Storefront cannot see this product \u2014 deactivated.",
7443
+ request: request2,
7444
+ result: { status: "inactive" },
7445
+ writes: [
7446
+ {
7447
+ collection: "product",
7448
+ data: { $set: { status: "inactive" } },
7449
+ operation: "update",
7450
+ query: { id: row2.id }
7451
+ },
7452
+ {
7453
+ collection: "advertisement",
7454
+ data: { $set: { status: "drafted" } },
7455
+ multiple: true,
7456
+ operation: "update",
7457
+ query: { product: row2.id }
7458
+ }
7459
+ ]
7460
+ };
7461
+ }
7462
+ const variants = await applyOutOfStock({
7463
+ domain,
7464
+ shopify,
7465
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7466
+ variants: (((_b = product == null ? void 0 : product.variants) == null ? void 0 : _b.edges) || []).map(toVariant)
7467
+ });
7468
+ const failed = await sendFeedback({
7469
+ adminToken,
7470
+ connection,
7471
+ messages: [],
7472
+ productId: providerId,
7473
+ shopify,
7474
+ state: "ACCEPTED"
7475
+ });
7476
+ return {
7477
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
7478
+ // written above; a product with thousands of variants becomes N short
7479
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
7480
+ enqueues: [{
7481
+ data: { operation: "variants", product: row2.id },
7482
+ name: "sync",
7483
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7484
+ queue: "product.shopify.variants"
7485
+ }],
7486
+ failed: failed ? [failed.message] : void 0,
7487
+ message: "Synced " + (product.title || "product") + ".",
7488
+ request: request2,
7489
+ result: { status: "active", variants: variants.length },
7490
+ writes: [{
7491
+ collection: "product",
7492
+ data: {
7493
+ $set: {
7494
+ description: product.description || null,
7495
+ image: ((_c = product.featuredImage) == null ? void 0 : _c.url) || null,
7496
+ // A successful sync PROVES channel visibility, which reverses the
7497
+ // deactivation above. Ads stay drafted.
7498
+ status: "active",
7499
+ title: product.title || null,
7500
+ type: product.productType || null,
7501
+ url: domain && product.handle ? "https://" + domain + "/products/" + product.handle : null,
7502
+ variants
7503
+ }
7504
+ },
7505
+ operation: "update",
7506
+ query: { id: row2.id }
7507
+ }]
7508
+ };
7509
+ };
7510
+ var syncVariants = async ({ connection, context, read, settings, shopify }) => {
7511
+ var _a, _b, _c;
7512
+ const row2 = await resolveProduct({ connection, context, read });
7513
+ if (!row2) return { message: "No product row to paginate.", request: { product: (context == null ? void 0 : context.product) || null }, skipped: true };
7514
+ const cursor = (context == null ? void 0 : context.cursor) || null;
7515
+ const request2 = { cursor, product: row2.id };
7516
+ let page;
7517
+ try {
7518
+ page = await shopify.storefront.getProductVariantsPage({
7519
+ cursor,
7520
+ domain: connection.shop,
7521
+ productId: (_a = row2.provider) == null ? void 0 : _a.id,
7522
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken
7523
+ });
7524
+ } catch (error) {
7525
+ if (error.message !== NOT_FOUND) throw error;
7526
+ return { message: "Storefront lost this product mid-pagination \u2014 stopping.", request: request2, skipped: true };
7527
+ }
7528
+ const variants = await applyOutOfStock({
7529
+ domain: connection.shop,
7530
+ shopify,
7531
+ storefrontAccessToken: settings == null ? void 0 : settings.storefrontAccessToken,
7532
+ variants: (page.edges || []).map(toVariant)
7533
+ });
7534
+ return {
7535
+ ...((_b = page.pageInfo) == null ? void 0 : _b.hasNextPage) && { enqueues: [{
7536
+ data: { cursor: page.pageInfo.endCursor, operation: "variants", product: row2.id },
7537
+ name: "page",
7538
+ options: { jobId: "product.shopify.variants." + row2.id + "." + Date.now() },
7539
+ queue: "product.shopify.variants"
7540
+ }] },
7541
+ message: variants.length + " variant(s) on this page.",
7542
+ request: request2,
7543
+ result: { hasNextPage: Boolean((_c = page.pageInfo) == null ? void 0 : _c.hasNextPage), variants: variants.length },
7544
+ writes: [{
7545
+ collection: "product",
7546
+ data: cursor ? { $push: { variants: { $each: variants } } } : { $set: { variants } },
7547
+ operation: "update",
7548
+ query: { id: row2.id }
7549
+ }]
7550
+ };
7551
+ };
5231
7552
  var shopify_default2 = {
5232
7553
  // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
5233
7554
  // a BUTTON LABEL inside the copy object next to a url — the title and the
@@ -5414,8 +7735,8 @@ var shopify_default2 = {
5414
7735
  // string; this answers what is missing from it.
5415
7736
  //
5416
7737
  // `shopify` is injected for the same reason it is everywhere else — this
5417
- // package cannot import @drawbridge/shopify, which depends on it.
5418
- scopes: ({ scope }, { shopify } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
7738
+ // client is imported here and defaulted, never wired in by a caller.
7739
+ scopes: ({ scope }, { shopify = client } = {}) => ({ result: { missing: scope ? shopify.oauth.missingScopes(scope) : null } }),
5419
7740
  // Shopify's install grant is exchanged inside its own app flow, not
5420
7741
  // through the shared OAuth runner.
5421
7742
  token: false
@@ -5429,17 +7750,53 @@ var shopify_default2 = {
5429
7750
  // can reference another), `dispatch` (the caller's own coordinator table,
5430
7751
  // for the hooks that are dispatches).
5431
7752
  commerce: {
5432
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
5433
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
5434
- // exactly why it never became a hook: a hook is reached to have its effects
5435
- // performed, and this one has none to perform. It moves here when the vendor
5436
- // client moves into the manifest and `subdomain.js` stops importing the
5437
- // package directly.
5438
- cart: false,
7753
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
7754
+ // the storefront client from api/route/subdomain.js.
7755
+ //
7756
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
7757
+ // become a hook a hook is usually reached to have its effects performed
7758
+ // and this one has none. That reasoning was wrong: a hook is also the one
7759
+ // place a vendor's client is allowed to be called from, and leaving the
7760
+ // cart out meant a public route importing a vendor SDK directly.
7761
+ //
7762
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
7763
+ // uses for its topic table. Six slots would be six names in the closed
7764
+ // vocabulary for one question — what does this shopper's cart look like
7765
+ // now — asked six ways.
7766
+ //
7767
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
7768
+ // mutations answer "that variant is sold out" as a userError rather than a
7769
+ // transport failure, and the route it replaced already told the two apart.
7770
+ // runHook carries `status` off a thrown error, so it survives to the
7771
+ // caller; the rest of a thrown error's shape does not.
7772
+ cart: async ({ clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {}) => {
7773
+ const { cartId, lineIds, lines, operation } = context || {};
7774
+ const { domain, storefrontAccessToken } = settings || {};
7775
+ if (operation === "checkout") {
7776
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
7777
+ return { message: "Checkout token minted.", result: { token } };
7778
+ }
7779
+ const storefront = { domain, fetcher, storefrontAccessToken };
7780
+ try {
7781
+ const cart = await ({
7782
+ create: () => shopify.storefront.cartCreate({ ...storefront, lines }),
7783
+ get: () => shopify.storefront.cartGet({ ...storefront, cartId }),
7784
+ linesAdd: () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
7785
+ linesRemove: () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
7786
+ linesUpdate: () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
7787
+ }[operation] || (() => {
7788
+ throw Object.assign(new Error("Unknown cart operation: " + operation), { status: 400 });
7789
+ }))();
7790
+ return { message: "Cart " + operation + ".", request: { cartId: cartId || null, operation }, result: cart };
7791
+ } catch (error) {
7792
+ if (error == null ? void 0 : error.userError) throw Object.assign(error, { status: 400 });
7793
+ throw error;
7794
+ }
7795
+ },
5439
7796
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
5440
7797
  // one lead — which is what lets an order that redeems it be attributed
5441
7798
  // back.
5442
- code: async ({ connection, context, step }, { adminToken, shopify } = {}) => {
7799
+ code: async ({ connection, context, step }, { adminToken, shopify = client } = {}) => {
5443
7800
  var _a;
5444
7801
  const discount = (_a = step.settings) == null ? void 0 : _a.discount;
5445
7802
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
@@ -5485,7 +7842,7 @@ var shopify_default2 = {
5485
7842
  // customer at the store is a support ticket: the context may already
5486
7843
  // carry the id from an earlier step, the lead may already be linked from
5487
7844
  // an earlier run, and Shopify's own get-or-create settles the rest.
5488
- customer: async ({ connection, context }, { adminToken, read, shopify } = {}) => {
7845
+ customer: async ({ connection, context }, { adminToken, read, shopify = client } = {}) => {
5489
7846
  const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection.shop };
5490
7847
  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 };
5491
7848
  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 };
@@ -5535,6 +7892,30 @@ var shopify_default2 = {
5535
7892
  }]
5536
7893
  };
5537
7894
  },
7895
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
7896
+ // rendered rather than stored on the product, because stock is the one
7897
+ // product fact that is stale the instant it is written down.
7898
+ //
7899
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
7900
+ // caller wanted the total; the one that existed summed it inline, and
7901
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
7902
+ // bookkeeping key got counted as a quantity.
7903
+ inventory: async ({ connection, context }, { adminToken, shopify = client } = {}) => {
7904
+ const productId = context == null ? void 0 : context.product;
7905
+ const request2 = { product: productId || null, shop: connection.shop };
7906
+ if (!productId) return { message: "No product id \u2014 nothing to count.", request: request2, result: { total: null }, skipped: true };
7907
+ const adminAccessToken = await adminToken();
7908
+ const byVariant = await shopify.admin.getProductInventory({
7909
+ adminAccessToken,
7910
+ domain: connection.shop,
7911
+ productId
7912
+ });
7913
+ return {
7914
+ message: "Inventory read.",
7915
+ request: request2,
7916
+ result: { total: Object.values(byVariant || {}).reduce((sum, quantity) => sum + quantity, 0) }
7917
+ };
7918
+ },
5538
7919
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
5539
7920
  // attribution genuinely is: an order can reach Drawbridge two ways and
5540
7921
  // they bill differently.
@@ -5855,6 +8236,12 @@ var shopify_default2 = {
5855
8236
  orderId: String(orderId),
5856
8237
  rate,
5857
8238
  shopId: connection.source.id,
8239
+ // WHICH VENDOR IS BEING CHARGED, so the sender does not have
8240
+ // to assume. queue/usage.js named the slug itself and
8241
+ // defaults to it for anything already queued when this
8242
+ // shipped — an app-store vendor billing through its own meter
8243
+ // enqueues the same job with its own slug.
8244
+ slug: connection.slug,
5858
8245
  // The App Events API returns no event id, so one is generated
5859
8246
  // here — the event handle plus the order id — and sent as the
5860
8247
  // event's `reference`. queue/usage.js stamps the same id onto
@@ -5894,10 +8281,28 @@ var shopify_default2 = {
5894
8281
  //
5895
8282
  // The shell has already refused a missing or inactive Shopify connection,
5896
8283
  // so what is left is the two things only this hook can know are wrong.
5897
- product: async ({ connection, context, workflow }, { mintId, read } = {}) => {
8284
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
8285
+ // slots in the closed vocabulary, the same shape inbound.process uses
8286
+ // for its topic table — they are one subject, and a vendor that syncs
8287
+ // products has to answer all three or none.
8288
+ //
8289
+ // record a webhook said a product changed: upsert the row and
8290
+ // queue the pull
8291
+ // sync pull the product from the storefront and write what it
8292
+ // says, including what it says by NOT answering
8293
+ // variants one page of variants, self-chaining
8294
+ //
8295
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
8296
+ // queue/product.js — a shadow implementation of the step this manifest
8297
+ // already declared, importing three of the vendor's client modules from
8298
+ // a worker file.
8299
+ product: async ({ connection, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {}) => {
8300
+ const operation = (context == null ? void 0 : context.operation) || "record";
8301
+ if (operation === "sync") return syncProduct({ adminToken, connection, context, read, settings, shopify });
8302
+ if (operation === "variants") return syncVariants({ connection, context, read, settings, shopify });
5898
8303
  const request2 = {
5899
8304
  numericId: (context == null ? void 0 : context.id) || null,
5900
- organizationId: workflow.organization,
8305
+ organizationId: (workflow == null ? void 0 : workflow.organization) || null,
5901
8306
  title: (context == null ? void 0 : context.title) || null
5902
8307
  };
5903
8308
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
@@ -5947,9 +8352,612 @@ var shopify_default2 = {
5947
8352
  }
5948
8353
  },
5949
8354
  contacts: { remove: false, sync: false },
5950
- // The embedded-app surface. Only a vendor whose merchants arrive from its
5951
- // own app store has one.
5952
- install: false,
8355
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
8356
+ // own app store has one, and these thirteen slots are the thirteen routes
8357
+ // that used to live in api/route/shopify-embedded.js.
8358
+ //
8359
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
8360
+ // holds a controller — `read` is the controller's read methods and nothing
8361
+ // else. Where one REFUSES it throws with a status, and the route answers
8362
+ // what it said. The helpers they share are above the manifest.
8363
+ install: {
8364
+ account: {
8365
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
8366
+ // authenticates the merchant once, top-level, and stashes their user
8367
+ // token in `install.token.save`; App Bridge has already proven shop
8368
+ // control by the time this runs.
8369
+ //
8370
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
8371
+ // token: the presented one must BE this shop's one-shot handoff token,
8372
+ // unexpired, and an access or pat record — a refresh or oauth-state
8373
+ // token that happens to resolve to a user must not qualify, which is
8374
+ // the rule a session is held to in drawbridge-api's middleware.
8375
+ link: async ({ context }, { read } = {}) => {
8376
+ var _a;
8377
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8378
+ const token = context == null ? void 0 : context.token;
8379
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8380
+ const now = /* @__PURE__ */ new Date();
8381
+ const record = await read.get({ collection: "shop", query: { shop } });
8382
+ const stashed = (record == null ? void 0 : record.linkToken) ? (_a = decrypt(record.linkToken)) == null ? void 0 : _a.token : null;
8383
+ const expired = !(record == null ? void 0 : record.linkTokenExpiresAt) || new Date(record.linkTokenExpiresAt) <= now;
8384
+ if (!stashed || expired || stashed !== token) throw refusal("Invalid or expired link token", 401);
8385
+ const granted = await read.get({
8386
+ collection: "token",
8387
+ query: {
8388
+ revoked: false,
8389
+ tokenHash: hashToken(token),
8390
+ type: { $in: [tokenTypes.access, tokenTypes.pat] }
8391
+ }
8392
+ });
8393
+ if (!(granted == null ? void 0 : granted.user) || granted.expiresAt && new Date(granted.expiresAt) <= now) {
8394
+ throw refusal("Invalid or expired link token", 401);
8395
+ }
8396
+ const switching = Boolean(record == null ? void 0 : record.user) && record.user !== granted.user;
8397
+ const connections2 = switching ? await read.aggregate({
8398
+ collection: "connection",
8399
+ pipeline: [
8400
+ { $match: { shop, slug: "shopify" } },
8401
+ { $project: { _id: 0, id: 1 } }
8402
+ ]
8403
+ }) : [];
8404
+ return {
8405
+ message: switching ? "Linked to a different Drawbridge account." : "Linked.",
8406
+ request: { shop },
8407
+ result: { connected: true, switched: switching },
8408
+ // ALL OR NONE. A half-done switch is a store linked to the new
8409
+ // account while the old account's connections still serve from it.
8410
+ transaction: true,
8411
+ writes: [
8412
+ ...(connections2 || []).map((connection) => ({
8413
+ collection: "connection",
8414
+ operation: "delete",
8415
+ query: { id: connection.id }
8416
+ })),
8417
+ {
8418
+ collection: "shop",
8419
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: granted.user } },
8420
+ operation: "update",
8421
+ query: { shop }
8422
+ }
8423
+ ]
8424
+ };
8425
+ },
8426
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
8427
+ // the durable link clears; the offline token and the shop record stay,
8428
+ // because the app is still installed and the merchant may link a
8429
+ // different account next.
8430
+ unlink: async ({ context }, { read } = {}) => {
8431
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8432
+ if (!shop) throw refusal("shop is required", 400);
8433
+ const connections2 = await read.aggregate({
8434
+ collection: "connection",
8435
+ pipeline: [
8436
+ { $match: { shop, slug: "shopify" } },
8437
+ { $project: { _id: 0, id: 1 } }
8438
+ ]
8439
+ });
8440
+ return {
8441
+ message: "Unlinked " + (connections2 || []).length + " connection(s).",
8442
+ request: { shop },
8443
+ result: { ok: true },
8444
+ transaction: true,
8445
+ writes: [
8446
+ ...(connections2 || []).map((connection) => ({
8447
+ collection: "connection",
8448
+ operation: "delete",
8449
+ query: { id: connection.id }
8450
+ })),
8451
+ {
8452
+ collection: "shop",
8453
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null, user: null } },
8454
+ operation: "update",
8455
+ query: { shop }
8456
+ }
8457
+ ]
8458
+ };
8459
+ }
8460
+ },
8461
+ organizations: {
8462
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
8463
+ // gate in it came from a line in the route it replaces.
8464
+ add: async ({ context }, { currencies, mintId, read, shopify = client } = {}) => {
8465
+ var _a, _b;
8466
+ const organization = context == null ? void 0 : context.organization;
8467
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8468
+ if (!shop) throw refusal("shop is required", 400);
8469
+ if (!organization) throw refusal("organization is required", 400);
8470
+ const record = await read.get({ collection: "shop", query: { shop } });
8471
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8472
+ const owned = await merchantOrganizations({ read, user: record.user });
8473
+ const org = (owned || []).find((entry) => entry.id === organization);
8474
+ if (!org) throw refusal("No access to this organization", 403);
8475
+ if (!CONNECTABLE_STATUSES.includes(org.billingStatus)) {
8476
+ throw refusal("This organization can't accept connections right now \u2014 check its billing status in the dashboard", 403);
8477
+ }
8478
+ if (org.connection && org.connection.shop !== shop) {
8479
+ throw refusal("This organization is already connected to another Shopify store", 409);
8480
+ }
8481
+ const settings = (record == null ? void 0 : record.settings) ? decrypt(record.settings) : null;
8482
+ if (!(settings == null ? void 0 : settings.accessToken)) {
8483
+ throw refusal("Store is not installed \u2014 open the app in the Shopify admin first", 409);
8484
+ }
8485
+ const plan = await storePlan({ record, shopify });
8486
+ if (plan.unavailable) throw refusal("Couldn't verify the store's plan \u2014 try again shortly", 502);
8487
+ if (!plan.active) throw refusal("Approve a plan before connecting organizations", 402);
8488
+ let storefront = null;
8489
+ if (!record.storefront) {
8490
+ try {
8491
+ const minted2 = await shopify.oauth.createStorefrontToken({
8492
+ adminAccessToken: settings.accessToken,
8493
+ shop
8494
+ });
8495
+ if (minted2) storefront = encrypt({ token: minted2 });
8496
+ } catch (_) {
8497
+ }
8498
+ }
8499
+ let currency = (record == null ? void 0 : record.currency) || null;
8500
+ let source = (record == null ? void 0 : record.source) || null;
8501
+ try {
8502
+ const shopData = await shopify.oauth.getShop({ adminAccessToken: settings.accessToken, shop });
8503
+ if (shopData) {
8504
+ currency = String(shopData.currency || "").toLowerCase() || null;
8505
+ source = { domain: shopData.myshopifyDomain, id: String(shopData.id), label: shopData.name };
8506
+ }
8507
+ } catch (_) {
8508
+ }
8509
+ if (currency && currencies && !currencies.includes(currency)) {
8510
+ throw refusal("This store settles in a currency we can't bill yet. Connect a store with a supported settlement currency.", 422);
8511
+ }
8512
+ const minted = ((_a = org.connection) == null ? void 0 : _a.id) ? null : mintId();
8513
+ const connectionId = ((_b = org.connection) == null ? void 0 : _b.id) || minted.id;
8514
+ return {
8515
+ message: "Connected " + org.name + " to " + shop + ".",
8516
+ request: { organization, shop },
8517
+ result: { ok: true },
8518
+ transaction: true,
8519
+ writes: [
8520
+ ...storefront ? [{
8521
+ collection: "shop",
8522
+ data: { $set: { storefront } },
8523
+ operation: "update",
8524
+ query: { shop }
8525
+ }] : [],
8526
+ {
8527
+ collection: "connection",
8528
+ data: {
8529
+ $set: {
8530
+ currency,
8531
+ errors: [],
8532
+ // The token of record lives on the shared `shop` row; this
8533
+ // is the pointer that satisfies the required `settings`
8534
+ // string, and `auth.install.shared` declares how to read it.
8535
+ settings: encrypt({ ref: "shop", shop }),
8536
+ shop,
8537
+ status: "pending",
8538
+ ...source && { source }
8539
+ },
8540
+ $setOnInsert: {
8541
+ ...minted && { _id: minted._id, id: minted.id },
8542
+ feature: "organization:connection:shopify",
8543
+ organization,
8544
+ slug: "shopify"
8545
+ }
8546
+ },
8547
+ operation: "update",
8548
+ options: { upsert: true },
8549
+ query: { organization, slug: "shopify" }
8550
+ },
8551
+ {
8552
+ collection: "organization",
8553
+ data: {
8554
+ // $addToSet UNCONDITIONALLY. The route this replaces only
8555
+ // registered the refs when the upsert INSERTED, which meant an
8556
+ // org whose refs had drifted stayed drifted through every
8557
+ // re-link. Adding a member that is already there is a no-op,
8558
+ // so doing it every time costs nothing and repairs the drift.
8559
+ $addToSet: {
8560
+ "connections.groups": "ecommerce",
8561
+ "connections.ids": connectionId,
8562
+ "connections.keys": "shopify"
8563
+ },
8564
+ $set: { billingProvider: "shopify" }
8565
+ },
8566
+ operation: "update",
8567
+ query: { id: organization }
8568
+ }
8569
+ ]
8570
+ };
8571
+ },
8572
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
8573
+ // store's live plan, and the products the merchant has to act on.
8574
+ //
8575
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
8576
+ // `add` would refuse it — a list that offers what the next call rejects
8577
+ // is worse than a shorter one.
8578
+ list: async ({ context }, { conversionRate, planTitle, read, shopify = client } = {}) => {
8579
+ var _a;
8580
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8581
+ if (!shop) throw refusal("shop is required", 400);
8582
+ const record = await read.get({ collection: "shop", query: { shop } });
8583
+ if (!(record == null ? void 0 : record.user)) {
8584
+ return {
8585
+ message: "No account linked to this store.",
8586
+ request: { shop },
8587
+ result: {
8588
+ account: null,
8589
+ connected: false,
8590
+ organizations: [],
8591
+ plan: { active: false, name: null },
8592
+ unavailableProducts: [],
8593
+ unavailableProductsTotal: 0
8594
+ }
8595
+ };
8596
+ }
8597
+ const owned = await merchantOrganizations({ read, user: record.user });
8598
+ const organizations = (owned || []).filter((org) => !org.connection || org.connection.shop === shop).map((org) => {
8599
+ var _a2;
8600
+ return {
8601
+ // The org's OWN billing status decides whether `add` accepts it.
8602
+ // Sent up so the app can disable Connect and name the reason
8603
+ // rather than letting the merchant click into a 403 —
8604
+ // listed-but-blocked beats hidden, because an org vanishing from
8605
+ // the list is indistinguishable from a bug.
8606
+ billingStatus: org.billingStatus || null,
8607
+ connectable: CONNECTABLE_STATUSES.includes(org.billingStatus),
8608
+ connected: Boolean(org.connection),
8609
+ conversion: conversionRate(org.subscription),
8610
+ id: org.id,
8611
+ name: org.name,
8612
+ plan: planTitle(org.subscription),
8613
+ status: ((_a2 = org.connection) == null ? void 0 : _a2.status) || null
8614
+ };
8615
+ });
8616
+ const connections2 = (owned || []).filter((org) => {
8617
+ var _a2;
8618
+ return ((_a2 = org.connection) == null ? void 0 : _a2.shop) === shop && org.connection.id;
8619
+ }).map((org) => org.connection.id);
8620
+ const [account, plan, unavailable] = await Promise.all([
8621
+ read.get({ collection: "user", query: { id: record.user } }),
8622
+ storePlan({ record, shopify }),
8623
+ connections2.length ? read.aggregate({
8624
+ collection: "product",
8625
+ pipeline: [
8626
+ { $match: { connections: { $in: connections2 }, "source.domain": shop, status: "inactive" } },
8627
+ // $facet so the app can name the first 25 AND report a
8628
+ // truthful total: listing 25 of 40 under "25 products need
8629
+ // attention" would understate the problem.
8630
+ {
8631
+ $facet: {
8632
+ items: [{ $sort: { title: 1 } }, { $limit: 25 }, { $project: { _id: 0, id: 1, title: 1 } }],
8633
+ total: [{ $count: "count" }]
8634
+ }
8635
+ }
8636
+ ]
8637
+ }) : []
8638
+ ]);
8639
+ const facet = (unavailable || [])[0] || {};
8640
+ if (plan.active && plan.metered !== true) {
8641
+ const stamped = await read.aggregate({
8642
+ collection: "connection",
8643
+ pipeline: [
8644
+ { $match: { shop, slug: "shopify" } },
8645
+ { $project: { _id: 0, "source.metered": 1 } }
8646
+ ]
8647
+ });
8648
+ const verdicts = (stamped || []).map((row2) => {
8649
+ var _a2;
8650
+ return (_a2 = row2 == null ? void 0 : row2.source) == null ? void 0 : _a2.metered;
8651
+ });
8652
+ if (verdicts.some((verdict) => typeof verdict === "string")) plan.metered = true;
8653
+ else if (verdicts.some((verdict) => verdict === null)) plan.metered = false;
8654
+ }
8655
+ return {
8656
+ message: organizations.length + " organization(s) for this store.",
8657
+ request: { shop },
8658
+ result: {
8659
+ account: { email: (account == null ? void 0 : account.email) || null },
8660
+ connected: true,
8661
+ organizations,
8662
+ plan,
8663
+ unavailableProducts: (facet.items || []).map((product) => ({
8664
+ id: product.id,
8665
+ title: product.title || "Untitled product"
8666
+ })),
8667
+ // The total can exceed the 25 listed above, and has to.
8668
+ unavailableProductsTotal: ((_a = (facet.total || [])[0]) == null ? void 0 : _a.count) || 0
8669
+ }
8670
+ };
8671
+ },
8672
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
8673
+ // else: every dependent — products, ads, steps, workflows, and the org's
8674
+ // own connection refs — is torn down by drawbridge-sync's connection
8675
+ // change stream. The shared token and the other orgs are untouched.
8676
+ remove: async ({ context }, { read } = {}) => {
8677
+ const organization = context == null ? void 0 : context.organization;
8678
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8679
+ if (!shop) throw refusal("shop is required", 400);
8680
+ if (!organization) throw refusal("organization is required", 400);
8681
+ const record = await read.get({ collection: "shop", query: { shop } });
8682
+ if (!(record == null ? void 0 : record.user)) throw refusal("Account is not connected for this store", 409);
8683
+ const owned = await merchantOrganizations({ read, user: record.user });
8684
+ if (!(owned || []).some((entry) => entry.id === organization)) {
8685
+ throw refusal("No access to this organization", 403);
8686
+ }
8687
+ const connection = await read.get({
8688
+ collection: "connection",
8689
+ query: { organization, shop, slug: "shopify" }
8690
+ });
8691
+ if (!connection) throw refusal("No connection to remove", 404);
8692
+ return {
8693
+ message: "Disconnected.",
8694
+ request: { organization, shop },
8695
+ result: { ok: true },
8696
+ writes: [{ collection: "connection", operation: "delete", query: { id: connection.id } }]
8697
+ };
8698
+ }
8699
+ },
8700
+ plan: {
8701
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
8702
+ // and falls back to "Choose a plan".
8703
+ cancel: ({ context }, { read, shopify = client } = {}) => releaseApprovals({ context, read, shopify }),
8704
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
8705
+ // order step and run on a retrying queue, so this may be called
8706
+ // several times for one order — which is safe because the idempotency
8707
+ // key is PERMANENT for billing events and Shopify settles the repeat.
8708
+ //
8709
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
8710
+ // is a real strengthening of what it replaced: drawbridge-sync compared
8711
+ // the job's `handle` against the job's `transaction` — two fields from
8712
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
8713
+ // handle is the one string that decides whether the event bills at all,
8714
+ // and this is the file that publishes it.
8715
+ //
8716
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
8717
+ // idempotency key on an event that misclassifies or mistraces. The
8718
+ // throw rides the queue's normal retry path and the abandoned-job alert
8719
+ // pages on exhaustion.
8720
+ //
8721
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
8722
+ // which is always inside the merchant's current billing cycle at the
8723
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
8724
+ // purchase time reads more correct and is strictly worse: a retry
8725
+ // backoff crossing a cycle boundary would carry a closed-cycle
8726
+ // timestamp, Shopify would 202-accept, silently drop it, and the
8727
+ // commission would be lost. Send-time's worst case is benign — the
8728
+ // charge lands in the next cycle, but it lands.
8729
+ charge: async ({ clientId, clientSecret, context, manifest }, { shopify = client } = {}) => {
8730
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
8731
+ const handle = (context == null ? void 0 : context.handle) || manifest.auth.install.billing.handle;
8732
+ if (!transaction || !String(transaction).startsWith(handle + ".")) {
8733
+ throw new Error("shopify.usage.billing.handle.mismatch on order " + orderId + ": transaction " + (transaction || "null") + " does not carry handle " + handle + " \u2014 refusing to send");
8734
+ }
8735
+ await shopify.billing.sendAppEvent({
8736
+ clientId,
8737
+ clientSecret,
8738
+ eventHandle: handle,
8739
+ idempotencyKey,
8740
+ // The Shopify-side event record names the order that produced it.
8741
+ reference: transaction,
8742
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
8743
+ // send. `revision` suffixes the idempotency key, which is what
8744
+ // makes a second attempt possible once a key has been consumed;
8745
+ // the encoding is owned by sendAppEvent so no caller builds keys
8746
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
8747
+ // path must never do — see the note above — and the recovery
8748
+ // migration must, because it is resending orders from a closed
8749
+ // window and bounds its own run to the merchant's current cycle.
8750
+ ...revision && { revision },
8751
+ ...timestamp && { timestamp },
8752
+ shopId,
8753
+ value
8754
+ });
8755
+ return {
8756
+ message: "Usage charge sent for order " + orderId + ".",
8757
+ request: { order: order || null, orderId: orderId || null, shopId: shopId || null, value },
8758
+ result: { transaction: String(transaction) },
8759
+ // TRACEABILITY. The App Events API returns no event id, so the
8760
+ // trace key into Shopify's event logs is the transaction id the
8761
+ // commerce.order hook minted and this sent as `reference`.
8762
+ // `amount` is the per-order usage value in cents, mirroring the
8763
+ // Stripe path's marker.
8764
+ ...order && { writes: [{
8765
+ collection: "order",
8766
+ data: {
8767
+ $set: {
8768
+ billed: {
8769
+ amount: value,
8770
+ date: /* @__PURE__ */ new Date(),
8771
+ rate: rate || 0,
8772
+ transaction: String(transaction)
8773
+ }
8774
+ }
8775
+ },
8776
+ operation: "update",
8777
+ query: { id: order }
8778
+ }] }
8779
+ };
8780
+ },
8781
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
8782
+ // be released before the pricing page will offer the plan again — but
8783
+ // the intent is a thirty-second round trip rather than a departure.
8784
+ //
8785
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
8786
+ // needs one. The marker has to be persisted BEFORE the approval is
8787
+ // released: it is what tells sync's CANCELLED handler to hold the pause
8788
+ // — no connection error, no owner email — and start a timed recheck that
8789
+ // pauses honestly if the merchant abandons. A described write lands
8790
+ // after the hook returns, which is after the cancel, and a marker
8791
+ // written then can race the very webhook it exists to soften. Same
8792
+ // precedent as `resolveContact`: the one write a description cannot
8793
+ // carry.
8794
+ reselect: async ({ context }, { mark, read, shopify = client } = {}) => {
8795
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8796
+ if (!shop) throw refusal("shop is required", 400);
8797
+ await mark({ shop });
8798
+ return releaseApprovals({ context, read, shopify });
8799
+ },
8800
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
8801
+ // action — the other two slots here are — but the same subject, and
8802
+ // the reason it is a declared hook rather than a helper is that the
8803
+ // answer was being derived in two places: `lifecycle.health` below,
8804
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
8805
+ // stated in its own comment that it matched this one and did not.
8806
+ //
8807
+ // THREE ANSWERS, and the caller must keep them apart:
8808
+ //
8809
+ // a string PROVEN. The charge id or usage line id, stamped on
8810
+ // the connection as the evidence.
8811
+ // null PROBED, and nothing is accruing.
8812
+ // undefined NO VERDICT — the probe could not run. `probed` says
8813
+ // which, because undefined does not survive JSON.
8814
+ verify: async ({ context }, { adminToken, read, shopify = client } = {}) => {
8815
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8816
+ if (!shop) throw refusal("shop is required", 400);
8817
+ const adminAccessToken = await adminToken();
8818
+ if (!adminAccessToken) {
8819
+ return { message: "No admin token for this store \u2014 no verdict.", request: { shop }, result: { metered: null, probed: false }, skipped: true };
8820
+ }
8821
+ const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
8822
+ const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
8823
+ const { ledger, metered } = await meterVerdict({
8824
+ adminAccessToken,
8825
+ ledgerFloor: new Date(Date.now() - LEDGER_WINDOW_MS),
8826
+ partner,
8827
+ shop,
8828
+ shopId: (context == null ? void 0 : context.shopId) || null,
8829
+ shopify
8830
+ });
8831
+ return {
8832
+ message: typeof metered === "string" ? "Billing is metered." : "No meter evidence for this store.",
8833
+ request: { shop },
8834
+ // `probed` is the LEDGER, not the call: a store whose Partner
8835
+ // credentials are absent was not asked, and reporting that as
8836
+ // "probed, nothing accruing" would stamp an unmetered verdict on
8837
+ // a store nobody looked at.
8838
+ result: { metered: metered ?? null, probed: ledger || typeof metered === "string" }
8839
+ };
8840
+ }
8841
+ },
8842
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
8843
+ // persists the offline session on token exchange and refresh; it lives
8844
+ // encrypted on the shared `shop` record, which is the same row every
8845
+ // organization's connection to this store points at.
8846
+ session: {
8847
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
8848
+ // invalidation and not only on uninstall, so hard-deleting the record
8849
+ // would orphan every org connection pointing at it — token resolution
8850
+ // silently nulls — and destroy the durable account link and the
8851
+ // storefront token with it. The next embedded load re-runs token
8852
+ // exchange. The full record delete belongs to the uninstall webhook.
8853
+ clear: async ({ context }) => {
8854
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8855
+ if (!shop) throw refusal("shop is required", 400);
8856
+ return {
8857
+ message: "Session cleared.",
8858
+ request: { shop },
8859
+ result: { ok: true },
8860
+ writes: [{
8861
+ collection: "shop",
8862
+ data: { $set: { settings: null } },
8863
+ operation: "update",
8864
+ query: { shop }
8865
+ }]
8866
+ };
8867
+ },
8868
+ read: async ({ context }, { read } = {}) => {
8869
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8870
+ if (!shop) throw refusal("shop is required", 400);
8871
+ const record = await read.get({ collection: "shop", query: { shop } });
8872
+ if (!(record == null ? void 0 : record.settings)) throw refusal("No session for this store", 404);
8873
+ return {
8874
+ message: "Session read.",
8875
+ request: { shop },
8876
+ result: { session: decrypt(record.settings) }
8877
+ };
8878
+ },
8879
+ save: async ({ context }) => {
8880
+ const session = context == null ? void 0 : context.session;
8881
+ const shop = sanitizeDomain(session == null ? void 0 : session.shop);
8882
+ if (!shop || !(session == null ? void 0 : session.accessToken)) throw refusal("session.shop and session.accessToken are required", 400);
8883
+ return {
8884
+ message: "Session saved.",
8885
+ request: { shop },
8886
+ result: { ok: true },
8887
+ writes: [{
8888
+ collection: "shop",
8889
+ data: {
8890
+ $set: { settings: encrypt(session), shop },
8891
+ $setOnInsert: { status: "pending" }
8892
+ },
8893
+ operation: "update",
8894
+ options: { upsert: true },
8895
+ query: { shop }
8896
+ }]
8897
+ };
8898
+ }
8899
+ },
8900
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
8901
+ // a first-party cookie; the embedded App Home cannot read that cookie from
8902
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
8903
+ // user token here keyed by shop, and App Home reads it once and clears it.
8904
+ //
8905
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
8906
+ // mid-handoff. Short-lived and encrypted.
8907
+ token: {
8908
+ clear: async ({ context }) => {
8909
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8910
+ if (!shop) throw refusal("shop is required", 400);
8911
+ return {
8912
+ message: "Link token cleared.",
8913
+ request: { shop },
8914
+ result: { ok: true },
8915
+ writes: [{
8916
+ collection: "shop",
8917
+ data: { $set: { linkToken: null, linkTokenExpiresAt: null } },
8918
+ operation: "update",
8919
+ query: { shop }
8920
+ }]
8921
+ };
8922
+ },
8923
+ read: async ({ context }, { read } = {}) => {
8924
+ var _a;
8925
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8926
+ if (!shop) throw refusal("shop is required", 400);
8927
+ const record = await read.get({ collection: "shop", query: { shop } });
8928
+ const expiresAt = (record == null ? void 0 : record.linkTokenExpiresAt) ? new Date(record.linkTokenExpiresAt) : null;
8929
+ if (!(record == null ? void 0 : record.linkToken) || !expiresAt || expiresAt <= /* @__PURE__ */ new Date()) {
8930
+ throw refusal("No link token for this store", 404);
8931
+ }
8932
+ return {
8933
+ message: "Link token read.",
8934
+ request: { shop },
8935
+ result: { token: ((_a = decrypt(record.linkToken)) == null ? void 0 : _a.token) ?? null }
8936
+ };
8937
+ },
8938
+ save: async ({ context }) => {
8939
+ const shop = sanitizeDomain(context == null ? void 0 : context.shop);
8940
+ const token = context == null ? void 0 : context.token;
8941
+ if (!shop || !token) throw refusal("shop and token are required", 400);
8942
+ return {
8943
+ message: "Link token saved.",
8944
+ request: { shop },
8945
+ result: { ok: true },
8946
+ writes: [{
8947
+ collection: "shop",
8948
+ data: {
8949
+ $set: {
8950
+ linkToken: encrypt({ token }),
8951
+ linkTokenExpiresAt: new Date(Date.now() + LINK_TOKEN_TTL_MS)
8952
+ }
8953
+ },
8954
+ operation: "update",
8955
+ query: { shop }
8956
+ }]
8957
+ };
8958
+ }
8959
+ }
8960
+ },
5953
8961
  // Drawbridge sends its own notification email and SMS — see the private
5954
8962
  // `drawbridge` manifest. A vendor answering these would be a second sender,
5955
8963
  // which is the arrangement the platform sender replaced.
@@ -6011,8 +9019,8 @@ var shopify_default2 = {
6011
9019
  // access token still works, reconciles the scopes the store granted
6012
9020
  // against the ones the app now needs, and queues a webhook
6013
9021
  // reconciliation.
6014
- health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
6015
- var _a, _b, _c, _d, _e, _f;
9022
+ health: async ({ connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {}) => {
9023
+ var _a, _b, _c, _d;
6016
9024
  const request2 = {
6017
9025
  connectionId: workflow.connection,
6018
9026
  organizationId: workflow.organization,
@@ -6049,33 +9057,26 @@ var shopify_default2 = {
6049
9057
  shopCurrency = String(shopData.currency || "").toLowerCase() || null;
6050
9058
  sourceRepaired = true;
6051
9059
  }
6052
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
6053
- adminAccessToken,
6054
- domain: connection.shop
6055
- });
6056
- const usageLine = (_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId;
6057
- if (usageLine) metered = usageLine;
6058
9060
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
6059
9061
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
6060
- if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
6061
- const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
6062
- const accruals = await shopify.partner.getUsageChargeEvents({
6063
- appId: "gid://shopify/App/" + partner.partnerAppId,
6064
- first: 10,
6065
- occurredAtMin: ledgerFloor.toISOString(),
6066
- organizationId: partner.partnerOrgId,
6067
- partnerToken: partner.partnerToken,
6068
- shopId: "gid://shopify/Shop/" + source.id
6069
- });
6070
- const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
6071
- const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
6072
- if (accrued) metered = String(accrued.chargeId || accrued.id);
6073
- const stampedAt = (_c = boundaries.find(
9062
+ const ledgerFloor = new Date(Date.now() - LEDGER_WINDOW_MS);
9063
+ const verdict = await meterVerdict({
9064
+ adminAccessToken,
9065
+ ledgerFloor,
9066
+ partner,
9067
+ shop: connection.shop,
9068
+ shopId: source == null ? void 0 : source.id,
9069
+ shopify
9070
+ });
9071
+ if (verdict.metered !== void 0) metered = verdict.metered;
9072
+ const { boundaries } = verdict;
9073
+ if (boundaries.length) {
9074
+ const stampedAt = (_a = boundaries.find(
6074
9075
  (event) => {
6075
9076
  var _a2;
6076
9077
  return String(event.chargeId || event.id) === String(((_a2 = connection.source) == null ? void 0 : _a2.metered) || "");
6077
9078
  }
6078
- )) == null ? void 0 : _c.occurredAt;
9079
+ )) == null ? void 0 : _a.occurredAt;
6079
9080
  reconciliation = [];
6080
9081
  let windowStart = ledgerFloor;
6081
9082
  for (const boundary of boundaries) {
@@ -6125,7 +9126,7 @@ var shopify_default2 = {
6125
9126
  }
6126
9127
  });
6127
9128
  } else {
6128
- (_d = logger == null ? void 0 : logger.error) == null ? void 0 : _d.call(logger, new Error(
9129
+ (_b = logger == null ? void 0 : logger.error) == null ? void 0 : _b.call(logger, new Error(
6129
9130
  "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"
6130
9131
  ), {
6131
9132
  extra: {
@@ -6144,13 +9145,13 @@ var shopify_default2 = {
6144
9145
  }
6145
9146
  } catch (probeError) {
6146
9147
  billingProbeFailed = true;
6147
- (_e = logger == null ? void 0 : logger.warn) == null ? void 0 : _e.call(logger, "shopify.health.billing.probe.failed", {
9148
+ (_c = logger == null ? void 0 : logger.warn) == null ? void 0 : _c.call(logger, "shopify.health.billing.probe.failed", {
6148
9149
  connectionId: connection.id,
6149
9150
  message: probeError == null ? void 0 : probeError.message,
6150
9151
  shop: connection.shop
6151
9152
  });
6152
9153
  }
6153
- if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection.source) == null ? void 0 : _f.metered) ?? void 0)) {
9154
+ if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection.source) == null ? void 0 : _d.metered) ?? void 0)) {
6154
9155
  writes.push({
6155
9156
  collection: "connection",
6156
9157
  data: {
@@ -6285,13 +9286,13 @@ var shopify_default2 = {
6285
9286
  // They are the same two questions every other vendor answers through
6286
9287
  // resources.*, so they answer them the same way now.
6287
9288
  //
6288
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
9289
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
6289
9290
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
6290
9291
  // and nothing else — no controller, no collection access. Resolving the
6291
9292
  // credential is the caller's job because it is Drawbridge's job: the
6292
9293
  // admin token refreshes and writes itself back, which is service work,
6293
9294
  // not vendor work.
6294
- products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
9295
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify = client } = {}) => {
6295
9296
  var _a, _b, _c, _d;
6296
9297
  const products = await shopify.storefront.getProducts({
6297
9298
  cursor,
@@ -6313,7 +9314,7 @@ var shopify_default2 = {
6313
9314
  }
6314
9315
  };
6315
9316
  },
6316
- promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
9317
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify = client } = {}) => {
6317
9318
  var _a, _b;
6318
9319
  const discounts = await shopify.admin.getDiscounts({
6319
9320
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -6464,6 +9465,13 @@ var shopify_default2 = {
6464
9465
  key: "Shopify Order Tracking",
6465
9466
  queue: "connection",
6466
9467
  system: true,
9468
+ // THE TRIGGER THE REGISTER JOB HAND-WROTE. It is byte-identical to
9469
+ // the one on every stored workflow, because these documents exist on
9470
+ // prod with thousands of runs behind them and provisioning is
9471
+ // idempotent on (connection, system, title) — a different trigger
9472
+ // here would not be picked up by the existing rows anyway, and a
9473
+ // different TITLE would provision a second workflow for every store.
9474
+ trigger: { event: "shopify.order.create", type: "webhook" },
6467
9475
  type: "step.commerce.order.record"
6468
9476
  })
6469
9477
  },
@@ -6474,6 +9482,15 @@ var shopify_default2 = {
6474
9482
  key: "Shopify Product Sync",
6475
9483
  queue: "connection",
6476
9484
  system: true,
9485
+ // `shopify.product.update`, NOT `product.insert`. The plan named the
9486
+ // latter, and it is the right long-term answer — a Drawbridge product
9487
+ // row appearing should pull the vendor's copy, rather than
9488
+ // stream/product.js branching on the slug and enqueueing a
9489
+ // vendor-named queue. But a workflow carries ONE trigger, this one is
9490
+ // stored on prod against the webhook, and changing a live workflow's
9491
+ // trigger is a data migration. So the declaration matches what exists
9492
+ // and `product.insert` stays a separate, migration-gated change.
9493
+ trigger: { event: "shopify.product.update", type: "webhook" },
6477
9494
  type: "step.commerce.product.sync"
6478
9495
  })
6479
9496
  }
@@ -6495,26 +9512,37 @@ var shopify_default2 = {
6495
9512
  type: "step.connection.health.check"
6496
9513
  })
6497
9514
  },
6498
- // Audit-only. The "Shopify Token Activity" system workflow lists these
6499
- // for descriptive grouping, but its audit step docs are written manually
6500
- // at OAuth time — the workflow is never dispatched. Routing is declared
6501
- // defensively so that if it ever IS dispatched, the job lands on a real
6502
- // queue and the handler lookup misses cleanly instead of throwing
6503
- // "Unknown step type".
9515
+ // THE ONE WORKFLOW THAT CARRIES TWO STEPS, and the shape the plan called
9516
+ // the single genuinely new thing in it.
9517
+ //
9518
+ // Every other system step is its own workflow, titled by its own `key`.
9519
+ // These two are "Shopify Token Activity", one row in the merchant's list
9520
+ // with both steps in it — so they declare `workflow`, which names the
9521
+ // title, the trigger and the ORDER, and the provisioner groups on it.
9522
+ // Without that key the pair could only be provisioned by the hand-written
9523
+ // spec in drawbridge-sync's register job, which is exactly why that spec
9524
+ // outlived every other one.
9525
+ //
9526
+ // AUDIT-ONLY. The step documents are written manually at OAuth time and
9527
+ // the workflow is never dispatched; `queue` is declared defensively so
9528
+ // that if it ever IS, the job lands on a real queue and the handler lookup
9529
+ // misses cleanly rather than throwing "Unknown step type".
6504
9530
  token: {
6505
9531
  exchange: () => ({
6506
9532
  description: "Records the token exchange that completed an install. Audit only \u2014 never dispatched.",
6507
9533
  key: "Shopify Token Exchange",
6508
9534
  queue: "connection",
6509
9535
  system: true,
6510
- type: "step.connection.token.exchange"
9536
+ type: "step.connection.token.exchange",
9537
+ workflow: { key: "Shopify Token Activity", order: 0, trigger: { event: "shopify.token", type: "event" } }
6511
9538
  }),
6512
9539
  refresh: () => ({
6513
9540
  description: "Records a token rotation. Audit only \u2014 never dispatched.",
6514
9541
  key: "Shopify Token Refresh",
6515
9542
  queue: "connection",
6516
9543
  system: true,
6517
- type: "step.connection.token.refresh"
9544
+ type: "step.connection.token.refresh",
9545
+ workflow: { key: "Shopify Token Activity", order: 1, trigger: { event: "shopify.token", type: "event" } }
6518
9546
  })
6519
9547
  }
6520
9548
  }
@@ -6818,14 +9846,15 @@ var webhook_default = {
6818
9846
  "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
6819
9847
  "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."
6820
9848
  ],
6821
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6822
- // The dashboard already carries generic reasons for every way an
6823
- // authorization can fail declined, expired state, a vendor we are not set
6824
- // up for and a sentence here is an ADDITION to those, for a vendor whose
6825
- // failures need saying differently. This one's do not.
9849
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
9850
+ // nothing is authorized against a third party, so neither `denied` nor
9851
+ // `invalid` has a state it could describe. The failures this connection
9852
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
9853
+ // those reach them through the connection's own errors rather than through
9854
+ // connect copy.
6826
9855
  //
6827
- // Answered rather than omitted, because `errors` existed on two of six
6828
- // manifests and there was no way to tell "nothing to add" from "nobody
9856
+ // `false` is the recorded decision, not silence: `errors` existed on two of
9857
+ // six manifests and there was no way to tell "nothing to add" from "nobody
6829
9858
  // wrote it".
6830
9859
  errors: false,
6831
9860
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -6969,7 +9998,7 @@ var webhook_default = {
6969
9998
  // The connect prompt only where connecting is not already the card's whole
6970
9999
  // story: a disconnected or errored document renders a Connect action itself,
6971
10000
  // and a task repeating it talks over the button.
6972
- tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
10001
+ tasks: ({ settings, status } = {}) => status === "error" ? [] : (settings == null ? void 0 : settings.secret) ? [
6973
10002
  {
6974
10003
  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.',
6975
10004
  title: "Requests must be verified",
@@ -7056,6 +10085,77 @@ var attentive_default3 = {
7056
10085
  }
7057
10086
  };
7058
10087
 
10088
+ // lib/connections/icons/brightdata.js
10089
+ var brightdata_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
10090
+ <rect width="500" height="500" fill="#0F58FF"/>
10091
+ <circle cx="250" cy="250" r="52" fill="white"/>
10092
+ <rect x="234" y="70" width="32" height="96" rx="16" fill="white"/>
10093
+ <rect x="234" y="334" width="32" height="96" rx="16" fill="white"/>
10094
+ <rect x="70" y="234" width="96" height="32" rx="16" fill="white"/>
10095
+ <rect x="334" y="234" width="96" height="32" rx="16" fill="white"/>
10096
+ <rect x="117" y="140" width="32" height="96" rx="16" transform="rotate(-45 117 140)" fill="white"/>
10097
+ <rect x="304" y="327" width="32" height="96" rx="16" transform="rotate(-45 304 327)" fill="white"/>
10098
+ <rect x="360" y="117" width="32" height="96" rx="16" transform="rotate(45 360 117)" fill="white"/>
10099
+ <rect x="173" y="304" width="32" height="96" rx="16" transform="rotate(45 173 304)" fill="white"/>
10100
+ </svg>`;
10101
+
10102
+ // lib/connections/vendors/brightdata.js
10103
+ var brightdata_default2 = {
10104
+ fields: [
10105
+ {
10106
+ credential: "BRIGHTDATA_BROWSER_URI",
10107
+ input: "password",
10108
+ key: "browserUri",
10109
+ // A wss:// endpoint with the account's user and password in its
10110
+ // authority, which is why it is a password field rather than a url one:
10111
+ // the whole string is the credential.
10112
+ label: "Scraping Browser endpoint",
10113
+ message: "The wss:// Scraping Browser endpoint, carrying its own credentials. Used to render pages that need a real browser.",
10114
+ redact: true,
10115
+ required: true
10116
+ },
10117
+ {
10118
+ credential: "BRIGHTDATA_UNLOCKER_API_KEY",
10119
+ input: "password",
10120
+ key: "unlockerApiKey",
10121
+ label: "Web Unlocker API key",
10122
+ // NO EXPIRY FIELD, and that is a decision rather than an omission. Bright
10123
+ // Data offers an `Unlimited` expiry and recommends against it; Drawbridge
10124
+ // takes the unlimited key deliberately, because there is no way to rotate
10125
+ // this programmatically and a date nothing can act on is a date that only
10126
+ // surfaces after it has already broken scraping. If a fixed expiry is ever
10127
+ // taken instead, the field to add is a stored date and a warning ahead of
10128
+ // it — not a rotation, which the vendor cannot support.
10129
+ 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.",
10130
+ redact: true,
10131
+ required: true
10132
+ },
10133
+ {
10134
+ credential: "BRIGHTDATA_UNLOCKER_ZONE",
10135
+ input: "text",
10136
+ key: "unlockerZone",
10137
+ label: "Web Unlocker zone",
10138
+ // Not secret — a zone is a label on the account, and it is shown rather
10139
+ // than redacted so an operator can confirm which one is in use without
10140
+ // clearing the field to read it.
10141
+ message: "The zone name the Unlocker requests run against.",
10142
+ required: true
10143
+ }
10144
+ ],
10145
+ icon: brightdata_default,
10146
+ name: "Bright Data",
10147
+ slug: "brightdata",
10148
+ // WHERE THE FACTS CAME FROM, read 2026-09-16.
10149
+ urls: {
10150
+ api: "https://docs.brightdata.com/api-reference/authentication",
10151
+ dashboard: "https://brightdata.com/cp/setting/users",
10152
+ // Bright Data has no scope model — a key carries the account's own access —
10153
+ // so there is nothing to link. `false` is the recorded answer rather than a
10154
+ // missing key.
10155
+ scopes: false
10156
+ }
10157
+ };
10158
+
7059
10159
  // lib/connections/vendors/hubspot.js
7060
10160
  var hubspot_default2 = {
7061
10161
  // WHAT AN ADMIN TYPES on the provider screen. `credential` names the env var a
@@ -7369,7 +10469,7 @@ var twilio_default2 = {
7369
10469
 
7370
10470
  // lib/connections/index.js
7371
10471
  var QUEUES = ["connection", "notification", "segment", "webhook"];
7372
- var implemented = (hooks, path) => {
10472
+ var implemented2 = (hooks, path) => {
7373
10473
  const hook = path.split(".").reduce((node, key) => node == null ? void 0 : node[key], hooks);
7374
10474
  return typeof hook === "function" || !!hook && typeof hook === "object";
7375
10475
  };
@@ -7431,7 +10531,7 @@ var checkIcon = (owner, icon) => {
7431
10531
  }
7432
10532
  };
7433
10533
  var vendors = Object.freeze(Object.fromEntries(
7434
- [attentive_default3, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
10534
+ [attentive_default3, brightdata_default2, hubspot_default2, klaviyo_default3, mailchimp_default3, sendgrid_default2, shopify_default3, twilio_default2].map((vendor) => [vendor.slug, vendor])
7435
10535
  ));
7436
10536
  var buildVendor = (vendor) => {
7437
10537
  if (!(vendor == null ? void 0 : vendor.slug)) throw new Error("A vendor needs a slug");
@@ -7458,7 +10558,7 @@ for (const [slug2, vendor] of Object.entries(vendors)) {
7458
10558
  buildVendor(vendor);
7459
10559
  }
7460
10560
  var build = (manifest) => {
7461
- 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;
10561
+ 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;
7462
10562
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
7463
10563
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
7464
10564
  if (manifest == null ? void 0 : manifest.private) throw new Error(manifest.slug + " declares private \u2014 that is feature : false now");
@@ -7487,7 +10587,7 @@ var build = (manifest) => {
7487
10587
  if (!field.hook.startsWith("resources.")) {
7488
10588
  throw new Error(manifest.slug + "." + field.key + " reads from " + field.hook + " \u2014 a picker may only read resources.*");
7489
10589
  }
7490
- if (!implemented(manifest.hooks, field.hook)) {
10590
+ if (!implemented2(manifest.hooks, field.hook)) {
7491
10591
  throw new Error(manifest.slug + "." + field.key + " reads " + field.hook + ", which this vendor does not implement");
7492
10592
  }
7493
10593
  }
@@ -7526,7 +10626,7 @@ var build = (manifest) => {
7526
10626
  }
7527
10627
  let callback = null;
7528
10628
  const type = kinds[0];
7529
- if (["install", "oauth"].includes(type) && !implemented(manifest.hooks, "lifecycle.health")) {
10629
+ if (["install", "oauth"].includes(type) && !implemented2(manifest.hooks, "lifecycle.health")) {
7530
10630
  throw new Error(
7531
10631
  manifest.slug + " is " + type + " and must implement hooks.lifecycle.health \u2014 a credential somebody else can revoke, expire or narrow tells us nothing when it dies"
7532
10632
  );
@@ -7555,10 +10655,10 @@ var build = (manifest) => {
7555
10655
  }
7556
10656
  callback = "/api/connection/" + manifest.slug + "/callback";
7557
10657
  }
7558
- if (implemented(manifest.hooks, "inbound.event") && typeof ((_n = (_m = manifest.hooks) == null ? void 0 : _m.inbound) == null ? void 0 : _n.event) !== "function" && !((_r = (_q = (_p = (_o = manifest.hooks) == null ? void 0 : _o.inbound) == null ? void 0 : _p.config) == null ? void 0 : _q.headers) == null ? void 0 : _r.event)) {
10658
+ if (implemented2(manifest.hooks, "inbound.event") && typeof ((_n = (_m = manifest.hooks) == null ? void 0 : _m.inbound) == null ? void 0 : _n.event) !== "function" && !((_r = (_q = (_p = (_o = manifest.hooks) == null ? void 0 : _o.inbound) == null ? void 0 : _p.config) == null ? void 0 : _q.headers) == null ? void 0 : _r.event)) {
7559
10659
  throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
7560
10660
  }
7561
- if (implemented(manifest.hooks, "inbound.verify") && !((_v = (_u = (_t = (_s = manifest.hooks) == null ? void 0 : _s.inbound) == null ? void 0 : _t.config) == null ? void 0 : _u.headers) == null ? void 0 : _v.signature)) {
10661
+ if (implemented2(manifest.hooks, "inbound.verify") && !((_v = (_u = (_t = (_s = manifest.hooks) == null ? void 0 : _s.inbound) == null ? void 0 : _t.config) == null ? void 0 : _u.headers) == null ? void 0 : _v.signature)) {
7562
10662
  throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
7563
10663
  }
7564
10664
  if (!Array.isArray((_w = manifest == null ? void 0 : manifest.content) == null ? void 0 : _w.guide) || !manifest.content.guide.length) {
@@ -7569,7 +10669,7 @@ var build = (manifest) => {
7569
10669
  if (Array.isArray(node)) {
7570
10670
  for (const verb of node) {
7571
10671
  const hook = declared2 == null ? void 0 : declared2[verb];
7572
- if (hook !== false && !implemented({ [verb]: hook }, verb)) {
10672
+ if (hook !== false && !implemented2({ [verb]: hook }, verb)) {
7573
10673
  throw new Error(manifest.slug + " must answer hooks." + [...path, verb].join(".") + " \u2014 false, a function, or {} if another repo implements it");
7574
10674
  }
7575
10675
  }
@@ -7715,7 +10815,7 @@ var build = (manifest) => {
7715
10815
  if (!QUEUES.includes(declared2 == null ? void 0 : declared2.queue)) {
7716
10816
  throw new Error(manifest.slug + " step " + type2 + " needs a queue \u2014 one of " + QUEUES.join(", "));
7717
10817
  }
7718
- if (declared2.hook && !implemented(manifest.hooks, declared2.hook)) {
10818
+ if (declared2.hook && !implemented2(manifest.hooks, declared2.hook)) {
7719
10819
  throw new Error(manifest.slug + " step " + type2 + " points at hook " + declared2.hook + ", which this vendor does not implement");
7720
10820
  }
7721
10821
  for (const trigger of declared2.triggers || []) {
@@ -7724,11 +10824,22 @@ var build = (manifest) => {
7724
10824
  manifest.slug + " step " + type2 + " may be triggered by " + trigger + ", which is not a trigger \u2014 one of " + Object.keys(TRIGGERS).join(", ")
7725
10825
  );
7726
10826
  }
7727
- if (declared2.trigger) {
7728
- const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === declared2.trigger.event && TRIGGERS[key].type === declared2.trigger.type)];
10827
+ if (declared2.workflow) {
10828
+ if (!declared2.workflow.key) {
10829
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no key \u2014 the title several steps share");
10830
+ }
10831
+ if (typeof declared2.workflow.order !== "number") {
10832
+ throw new Error(manifest.slug + " step " + type2 + " declares a workflow with no order \u2014 two steps in one row need a sequence");
10833
+ }
10834
+ if (declared2.trigger) {
10835
+ 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");
10836
+ }
10837
+ }
10838
+ for (const candidate of [declared2.trigger, (_y = declared2.workflow) == null ? void 0 : _y.trigger].filter(Boolean)) {
10839
+ const known2 = TRIGGERS[Object.keys(TRIGGERS).find((key) => TRIGGERS[key].event === candidate.event && TRIGGERS[key].type === candidate.type)];
7729
10840
  if (!known2) {
7730
10841
  throw new Error(
7731
- manifest.slug + " step " + type2 + " declares trigger { event : " + declared2.trigger.event + ", type : " + declared2.trigger.type + " }, which nothing dispatches"
10842
+ manifest.slug + " step " + type2 + " declares trigger { event : " + candidate.event + ", type : " + candidate.type + " }, which nothing dispatches"
7732
10843
  );
7733
10844
  }
7734
10845
  }
@@ -7798,13 +10909,14 @@ var publicSettingsBySlug = Object.fromEntries(
7798
10909
  var connectionSteps = (env = {}) => Object.entries(availableConnections(env)).flatMap(
7799
10910
  ([slug2, manifest]) => leaves(manifest.steps).map(([, step]) => ({ ...step({}), slug: slug2 }))
7800
10911
  );
10912
+ var implementsHook = (hooks, name) => implemented2(hooks, name);
7801
10913
  var hookSupport = (name) => ({
7802
- no: Object.keys(connections).filter((slug2) => !implemented(connections[slug2].hooks, name)),
7803
- yes: Object.keys(connections).filter((slug2) => implemented(connections[slug2].hooks, name))
10914
+ no: Object.keys(connections).filter((slug2) => !implemented2(connections[slug2].hooks, name)),
10915
+ yes: Object.keys(connections).filter((slug2) => implemented2(connections[slug2].hooks, name))
7804
10916
  });
7805
10917
  var connectFields = (slug2) => {
7806
10918
  var _a;
7807
- return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
10919
+ return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required: required2, search }) => ({
7808
10920
  ...copy && { copy: true },
7809
10921
  ...hook && { hook },
7810
10922
  ...from && { from },
@@ -7817,7 +10929,7 @@ var connectFields = (slug2) => {
7817
10929
  // Declared false only where the vendor cannot filter, so a picker does not
7818
10930
  // offer a search box that quietly searches one page.
7819
10931
  ...search === false && { search: false },
7820
- required: Boolean(required),
10932
+ required: Boolean(required2),
7821
10933
  // A UI hint, not a leak: the form uses it to stop requiring the field once
7822
10934
  // the connection exists, and to say "leave blank to keep" — because the GET
7823
10935
  // response deliberately never returns the stored value.
@@ -7957,6 +11069,7 @@ var resolveConnection = (item, data, env = {}) => {
7957
11069
  HOOK_PROPS,
7958
11070
  HOOK_SLOT_PROPS,
7959
11071
  INPUTS,
11072
+ MARKERS,
7960
11073
  OAUTH_ENDPOINTS,
7961
11074
  OAUTH_FIELDS,
7962
11075
  OUTCOMES,
@@ -7977,28 +11090,35 @@ var resolveConnection = (item, data, env = {}) => {
7977
11090
  buildVendor,
7978
11091
  catalogConnections,
7979
11092
  connectFields,
11093
+ connectionSettings,
7980
11094
  connectionSteps,
7981
11095
  connections,
7982
11096
  consentUrl,
7983
11097
  effectsOf,
7984
11098
  ensureSystemWorkflows,
7985
11099
  hookSupport,
11100
+ implementsHook,
7986
11101
  isStale,
7987
11102
  isStatus,
7988
11103
  mergeSettings,
11104
+ perform,
7989
11105
  pkcePair,
7990
11106
  projectConnection,
7991
11107
  publicConnectionKeys,
7992
11108
  publicSettingsBySlug,
11109
+ reconcileConnectionScopes,
7993
11110
  redactSettings,
11111
+ reference,
7994
11112
  resolveConnection,
7995
11113
  runHook,
7996
11114
  scopesMessage,
11115
+ spliced,
7997
11116
  stepLabels,
7998
11117
  stepQueues,
7999
11118
  stepRoutes,
8000
11119
  surviving,
8001
11120
  systemSteps,
11121
+ systemWorkflows,
8002
11122
  tokenSettings,
8003
11123
  vendors
8004
11124
  });