@drawbridge/drawbridge-utils 0.0.136 → 0.0.139

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.
@@ -565,6 +565,9 @@ var attentive_default2 = {
565
565
  },
566
566
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
567
567
  // reasonably keep several up to date at once.
568
+ // No vendor-facing event identities — declared empty so the contract stays
569
+ // explicit: a vendor that gains one adds it here, never as a side channel.
570
+ events: {},
568
571
  exclusive: false,
569
572
  feature: "organization:connection:attentive",
570
573
  fields: [
@@ -1745,6 +1748,9 @@ var drawbridge_default2 = {
1745
1748
  "Nothing to do. These steps are available in every workflow builder."
1746
1749
  ]
1747
1750
  },
1751
+ // No vendor-facing event identities — declared empty so the contract stays
1752
+ // explicit: a vendor that gains one adds it here, never as a side channel.
1753
+ events: {},
1748
1754
  exclusive: false,
1749
1755
  fields: [],
1750
1756
  group: "developer",
@@ -2529,6 +2535,9 @@ var klaviyo_default2 = {
2529
2535
  // current — so the exclusivity that once applied when these were SENDERS is
2530
2536
  // deliberately gone. That was removed once already; declaring it out loud is
2531
2537
  // what stops it coming back by inference.
2538
+ // No vendor-facing event identities — declared empty so the contract stays
2539
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2540
+ events: {},
2532
2541
  exclusive: false,
2533
2542
  feature: "organization:connection:klaviyo",
2534
2543
  fields: [
@@ -3005,6 +3014,9 @@ var mailchimp_default2 = {
3005
3014
  // picking two providers to send the same mail was meaningless. As contact
3006
3015
  // syncs they are destinations, and a merchant could reasonably keep several
3007
3016
  // up to date, so the exclusivity is gone.
3017
+ // No vendor-facing event identities — declared empty so the contract stays
3018
+ // explicit: a vendor that gains one adds it here, never as a side channel.
3019
+ events: {},
3008
3020
  exclusive: false,
3009
3021
  feature: "organization:connection:mailchimp",
3010
3022
  fields: [
@@ -3280,6 +3292,22 @@ var toCanonicalEmail = (value) => {
3280
3292
  return local + "@" + domain;
3281
3293
  };
3282
3294
 
3295
+ // lib/slugify.js
3296
+ import slug from "slugify";
3297
+ var slugify = (value, nochars = false) => {
3298
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
3299
+ const replacement = nochars ? "" : "-";
3300
+ return slug(
3301
+ value,
3302
+ {
3303
+ remove,
3304
+ replacement,
3305
+ lower: true,
3306
+ trim: true
3307
+ }
3308
+ );
3309
+ };
3310
+
3283
3311
  // lib/connections/providers/shopify.js
3284
3312
  var toLine = ({
3285
3313
  price,
@@ -3315,6 +3343,7 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3315
3343
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3316
3344
  );
3317
3345
  var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
3346
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3318
3347
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3319
3348
  var OAUTH_ERROR_SOURCE = "oauth";
3320
3349
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
@@ -3372,7 +3401,7 @@ var shopify_default2 = {
3372
3401
  guide: [
3373
3402
  "Open the Drawbridge listing on the Shopify App Store.",
3374
3403
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3375
- "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
3404
+ "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3376
3405
  "Come back here \u2014 the connections list updates on its own once the install lands."
3377
3406
  ],
3378
3407
  // Names where the link GOES rather than what it does: installing happens on
@@ -3386,6 +3415,29 @@ var shopify_default2 = {
3386
3415
  // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
3387
3416
  // attributed order two possible sources.
3388
3417
  exclusive: true,
3418
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
3419
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
3420
+ // see the same handle the order hook mints into transactions and enqueues.
3421
+ events: {
3422
+ order: {
3423
+ handle: ORDER_EVENT_HANDLE,
3424
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
3425
+ // managed-pricing meter system): a $0 recurring line plus this usage
3426
+ // line, approved by the org owner at Shopify's confirmation screen.
3427
+ // `terms` is the merchant-visible consent copy; "up to" because the
3428
+ // actual rate is plan-derived (conversionRate) and must never exceed
3429
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
3430
+ // charges — a record beyond it errors until the merchant raises it.
3431
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
3432
+ // via a re-approved subscription when a merchant outgrows it.
3433
+ pricing: {
3434
+ cappedAmount: 1e3,
3435
+ currency: "usd",
3436
+ name: "Drawbridge Orders",
3437
+ terms: "Up to 1.5% of revenue from orders attributed to Drawbridge campaigns"
3438
+ }
3439
+ }
3440
+ },
3389
3441
  feature: "organization:connection:shopify",
3390
3442
  fields: [
3391
3443
  {
@@ -3571,7 +3623,7 @@ var shopify_default2 = {
3571
3623
  // for one purchase. The hook used to loop for this itself; describing
3572
3624
  // the writes moved the retry to the queue, with the same guarantee.
3573
3625
  order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3574
- var _a, _b, _c, _d, _e, _f;
3626
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3575
3627
  const {
3576
3628
  advertisement,
3577
3629
  created_at: createdAt,
@@ -3813,8 +3865,26 @@ var shopify_default2 = {
3813
3865
  });
3814
3866
  }
3815
3867
  }
3816
- const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3868
+ const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3869
+ if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3870
+ (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3871
+ "shopify.usage.billing.skipped on order " + orderId + ": " + Math.round(fee * 100) + " cents not billed \u2014 connection " + (connection2 == null ? void 0 : connection2.id) + " has no source.id"
3872
+ ), {
3873
+ extra: {
3874
+ connectionId: connection2 == null ? void 0 : connection2.id,
3875
+ fee,
3876
+ orderDocId,
3877
+ orderId: String(orderId)
3878
+ }
3879
+ });
3880
+ }
3881
+ const enqueues = billable && ((_h = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _h.id) ? [{
3817
3882
  data: {
3883
+ // The meter handle travels WITH the job so the sender can
3884
+ // verify it against the transaction prefix and refuse a
3885
+ // drifted pair — idempotency keys are permanent, so a
3886
+ // mistraceable event can never be resent under its own key.
3887
+ handle: ORDER_EVENT_HANDLE,
3818
3888
  idempotencyKey: String(orderId),
3819
3889
  orderDocId,
3820
3890
  orderId: String(orderId),
@@ -3824,7 +3894,7 @@ var shopify_default2 = {
3824
3894
  // here — the event handle plus the order id — and sent as the
3825
3895
  // event's `reference`. queue/usage.js stamps the same id onto
3826
3896
  // the order as billed.transaction.
3827
- transaction: "drawbridge-orders." + orderId,
3897
+ transaction: ORDER_EVENT_HANDLE + "." + orderId,
3828
3898
  value: Math.round(fee * 100)
3829
3899
  },
3830
3900
  name: "billing",
@@ -3967,7 +4037,8 @@ var shopify_default2 = {
3967
4037
  // access token still works, reconciles the scopes the store granted
3968
4038
  // against the ones the app now needs, and queues a webhook
3969
4039
  // reconciliation.
3970
- health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4040
+ health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4041
+ var _a, _b;
3971
4042
  const request2 = {
3972
4043
  connectionId: workflow.connection,
3973
4044
  organizationId: workflow.organization,
@@ -3986,7 +4057,50 @@ var shopify_default2 = {
3986
4057
  }
3987
4058
  await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
3988
4059
  const scopesMissing = await reconcileScopes({ shop: connection2.shop });
4060
+ const writes = [];
4061
+ let source = connection2.source || null;
4062
+ let sourceRepaired = false;
4063
+ let metered = (source == null ? void 0 : source.metered) ?? null;
4064
+ let shopCurrency = null;
4065
+ let billingProbeFailed = false;
4066
+ try {
4067
+ if (!(source == null ? void 0 : source.id)) {
4068
+ const shopData = await shopify.oauth.getShop({ adminAccessToken, shop: connection2.shop });
4069
+ source = {
4070
+ domain: shopData.myshopifyDomain,
4071
+ id: String(shopData.id),
4072
+ label: shopData.name
4073
+ };
4074
+ shopCurrency = String(shopData.currency || "").toLowerCase() || null;
4075
+ sourceRepaired = true;
4076
+ }
4077
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
4078
+ adminAccessToken,
4079
+ domain: connection2.shop
4080
+ });
4081
+ if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4082
+ metered = subscriptions.some((subscription) => subscription.metered);
4083
+ }
4084
+ } catch (probeError) {
4085
+ billingProbeFailed = true;
4086
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4087
+ connectionId: connection2.id,
4088
+ message: probeError == null ? void 0 : probeError.message,
4089
+ shop: connection2.shop
4090
+ });
4091
+ }
4092
+ if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4093
+ writes.push({
4094
+ collection: "connection",
4095
+ data: {
4096
+ $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4097
+ },
4098
+ operation: "update",
4099
+ query: { id: connection2.id }
4100
+ });
4101
+ }
3989
4102
  return {
4103
+ writes,
3990
4104
  enqueues: [{
3991
4105
  data: {
3992
4106
  data: {
@@ -3999,13 +4113,19 @@ var shopify_default2 = {
3999
4113
  options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID() },
4000
4114
  queue: "connection"
4001
4115
  }],
4002
- message: (scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.",
4116
+ message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === false ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
4003
4117
  request: request2,
4004
4118
  response: {
4119
+ billingProbeFailed,
4120
+ metered,
4005
4121
  pingedAt: /* @__PURE__ */ new Date(),
4006
4122
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
4007
4123
  refreshTokenRotated,
4008
4124
  scopesMissing,
4125
+ // The live settlement currency, for an operator — never
4126
+ // written, see above.
4127
+ ...shopCurrency && { shopCurrency },
4128
+ sourceRepaired,
4009
4129
  webhookReconciliationQueued: true
4010
4130
  }
4011
4131
  };
@@ -4273,12 +4393,35 @@ var shopify_default2 = {
4273
4393
  //
4274
4394
  // Scope drift is NOT here: drawbridge-sync writes it onto the connection
4275
4395
  // document, and the document's own warnings render beside these.
4276
- tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
4277
- {
4278
- message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
4279
- title: "Choose a plan in Shopify"
4280
- }
4281
- ] : [],
4396
+ // Both cards carry `action : 'billing'`: the dashboard renders it as a real
4397
+ // button that mints the corrective subscription (POST .../billing) and sends
4398
+ // the owner to Shopify's confirmation screen the fix lives in OUR
4399
+ // dashboard, and the one Shopify-hosted step is the charge consent no app
4400
+ // can perform for a merchant.
4401
+ tasks: (data2) => {
4402
+ var _a;
4403
+ return [
4404
+ ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4405
+ {
4406
+ action: "billing",
4407
+ message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4408
+ title: "Approve your plan"
4409
+ }
4410
+ ] : [],
4411
+ // The store's ACTIVE approval carries no usage component — stamped by
4412
+ // the daily health check and the approval webhook (`source.metered`).
4413
+ // Approved pricing is never retroactive, so the fix is a fresh approval.
4414
+ // Gated on an EXPLICIT false — a connection the probe hasn't reached
4415
+ // (or couldn't read) shows nothing rather than nagging on silence.
4416
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === false ? [
4417
+ {
4418
+ action: "billing",
4419
+ message: "Order billing isn't set up for this store, so campaign-attributed orders aren't being charged. Approve the updated plan to fix it \u2014 one click here, one confirmation on Shopify.",
4420
+ title: "Fix order billing"
4421
+ }
4422
+ ] : []
4423
+ ];
4424
+ },
4282
4425
  title: "Shopify"
4283
4426
  };
4284
4427
 
@@ -4452,6 +4595,9 @@ var webhook_default = {
4452
4595
  // Nothing to be exclusive with — there is no second webhook vendor, and a
4453
4596
  // merchant with two endpoints is a step-level choice rather than a second
4454
4597
  // connection.
4598
+ // No vendor-facing event identities — declared empty so the contract stays
4599
+ // explicit: a vendor that gains one adds it here, never as a side channel.
4600
+ events: {},
4455
4601
  exclusive: false,
4456
4602
  feature: "organization:connection:webhook",
4457
4603
  fields: [
@@ -4743,16 +4889,16 @@ var connections = Object.freeze({
4743
4889
  (() => {
4744
4890
  var _a;
4745
4891
  const routes = {};
4746
- for (const [slug, manifest] of Object.entries(connections)) {
4892
+ for (const [slug2, manifest] of Object.entries(connections)) {
4747
4893
  for (const [name, step] of leaves(manifest.steps)) {
4748
4894
  const type = "step." + name;
4749
4895
  const queue = (_a = step({})) == null ? void 0 : _a.queue;
4750
4896
  if (routes[type] && routes[type].queue !== queue) {
4751
4897
  throw new Error(
4752
- "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
4898
+ "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug2 + " \u2014 one of them would be enqueued nowhere"
4753
4899
  );
4754
4900
  }
4755
- routes[type] = { queue, slug };
4901
+ routes[type] = { queue, slug: slug2 };
4756
4902
  }
4757
4903
  }
4758
4904
  })();
@@ -4765,21 +4911,21 @@ var catalogConnections = (env = {}) => Object.fromEntries(
4765
4911
  Object.entries(availableConnections(env)).filter(([, manifest]) => !manifest.private)
4766
4912
  );
4767
4913
  var publicSettingsBySlug = Object.fromEntries(
4768
- Object.entries(connections).map(([slug, manifest]) => [
4769
- slug,
4914
+ Object.entries(connections).map(([slug2, manifest]) => [
4915
+ slug2,
4770
4916
  manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
4771
4917
  ])
4772
4918
  );
4773
4919
  var connectionSteps = (env = {}) => Object.entries(availableConnections(env)).flatMap(
4774
- ([slug, manifest]) => leaves(manifest.steps).map(([name, step]) => ({ ...step({}), slug, type: "step." + name }))
4920
+ ([slug2, manifest]) => leaves(manifest.steps).map(([name, step]) => ({ ...step({}), slug: slug2, type: "step." + name }))
4775
4921
  );
4776
4922
  var hookSupport = (name) => ({
4777
- no: Object.keys(connections).filter((slug) => !implemented(connections[slug].hooks, name)),
4778
- yes: Object.keys(connections).filter((slug) => implemented(connections[slug].hooks, name))
4923
+ no: Object.keys(connections).filter((slug2) => !implemented(connections[slug2].hooks, name)),
4924
+ yes: Object.keys(connections).filter((slug2) => implemented(connections[slug2].hooks, name))
4779
4925
  });
4780
- var connectFields = (slug) => {
4926
+ var connectFields = (slug2) => {
4781
4927
  var _a;
4782
- return (((_a = connections[slug]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
4928
+ return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
4783
4929
  ...copy && { copy: true },
4784
4930
  ...hook && { hook },
4785
4931
  ...from && { from },
@@ -4799,18 +4945,18 @@ var connectFields = (slug) => {
4799
4945
  secret: Boolean(redact)
4800
4946
  }));
4801
4947
  };
4802
- var runHook = async (slug, name, props = {}, options = {}) => {
4803
- const manifest = connections[slug];
4804
- if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug };
4948
+ var runHook = async (slug2, name, props = {}, options = {}) => {
4949
+ const manifest = connections[slug2];
4950
+ if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug2 };
4805
4951
  const hook = name.split(".").reduce(
4806
4952
  (node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
4807
4953
  manifest.hooks
4808
4954
  );
4809
4955
  if (hook === false || hook == null) {
4810
- return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
4956
+ return { outcome: OUTCOMES.unsupported, reason: slug2 + " does not implement " + name };
4811
4957
  }
4812
4958
  if (typeof hook !== "function") {
4813
- return { outcome: OUTCOMES.unimplemented, reason: slug + " implements " + name + " outside this package" };
4959
+ return { outcome: OUTCOMES.unimplemented, reason: slug2 + " implements " + name + " outside this package" };
4814
4960
  }
4815
4961
  try {
4816
4962
  const result = await hook({ ...props, manifest }, options);
@@ -4845,9 +4991,9 @@ var mergeSettings = ({ existing, incoming }) => {
4845
4991
  ;
4846
4992
  return merged;
4847
4993
  };
4848
- var redactSettings = ({ slug, settings }) => {
4994
+ var redactSettings = ({ slug: slug2, settings }) => {
4849
4995
  if (!settings || typeof settings !== "object") return settings;
4850
- const allowed = publicSettingsBySlug[slug] || [];
4996
+ const allowed = publicSettingsBySlug[slug2] || [];
4851
4997
  return Object.fromEntries(
4852
4998
  Object.entries(settings).filter(([key]) => allowed.includes(key))
4853
4999
  );